From 719632cda47eea6ec9cd0b973deae2a0c69e9356 Mon Sep 17 00:00:00 2001 From: juprodh Date: Thu, 21 May 2026 16:00:11 +0800 Subject: [PATCH] Add sanitization fixes --- bun.lock | 39 ++++ package.json | 3 + public/csrf.js | 41 ++++ public/editor/preview.ts | 19 +- public/editor/upload.ts | 3 + public/home-picker.js | 75 +++++++ public/ui.js | 229 +++++++++++++++++++++ src/atproto/routes.ts | 10 +- src/lib/access.ts | 26 +-- src/lib/csrf.ts | 61 ++++++ src/lib/markdown.ts | 3 +- src/lib/markdown/sanitize.ts | 153 ++++++++++++++ src/lib/response.ts | 28 ++- src/server/app.ts | 2 + src/server/context-plugin.ts | 21 ++ src/server/routes/blob.ts | 33 +-- src/server/routes/bookmark.ts | 17 +- src/server/routes/explore.ts | 7 +- src/server/routes/home.ts | 42 ++-- src/server/routes/membership.ts | 7 +- src/server/routes/note.ts | 11 +- src/server/routes/profile.ts | 10 +- src/server/routes/search.ts | 18 +- src/server/routes/wiki.ts | 25 ++- src/views/edit-note.ts | 3 +- src/views/history.ts | 2 +- src/views/layout.ts | 60 ++---- src/views/new-note.ts | 4 +- src/views/new-wiki.ts | 7 +- src/views/settings.ts | 73 +------ src/views/wiki-list.ts | 4 +- tests/integration/http-helpers.ts | 16 ++ tests/lib/csrf.test.ts | 39 ++++ tests/lib/import-export/export.test.ts | 1 + tests/lib/import-export/import.test.ts | 1 + tests/lib/markdown.test.ts | 6 +- tests/lib/orchestrators/membership.test.ts | 1 + tests/lib/orchestrators/note.test.ts | 1 + tests/lib/orchestrators/wiki.test.ts | 2 + tests/lib/viz/plugin.test.ts | 7 +- tests/server/routes/bookmark.test.ts | 16 ++ tests/server/routes/helpers.ts | 20 +- 42 files changed, 927 insertions(+), 219 deletions(-) create mode 100644 public/csrf.js create mode 100644 public/home-picker.js create mode 100644 public/ui.js create mode 100644 src/lib/csrf.ts create mode 100644 src/lib/markdown/sanitize.ts create mode 100644 src/server/context-plugin.ts create mode 100644 tests/lib/csrf.test.ts diff --git a/bun.lock b/bun.lock index 1fec628..ff4aa0e 100644 --- a/bun.lock +++ b/bun.lock @@ -23,10 +23,12 @@ "@sindresorhus/slugify": "^3.0.0", "diff": "^9.0.0", "diff-match-patch": "^1.0.5", + "dompurify": "^3.4.5", "elysia": "^1.4.28", "fflate": "^0.8.2", "katex": "^0.16.45", "markdown-it": "^14.1.1", + "sanitize-html": "^2.17.4", "sharp": "^0.34.5", }, "devDependencies": { @@ -37,6 +39,7 @@ "@types/d3": "^7.4.3", "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", + "@types/sanitize-html": "^2.16.1", "@typescript/native-preview": "^7.0.0-dev.20260507.1", "htmx.org": "^2.0.10", "knip": "^6.12.1", @@ -449,6 +452,10 @@ "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + "@types/sanitize-html": ["@types/sanitize-html@2.16.1", "", { "dependencies": { "htmlparser2": "^10.1" } }, "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260508.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260508.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260508.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260508.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260508.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260508.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260508.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260508.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-YOkOVCg9oyVbC45mdHpxMuRujVMiK9jSsf82w+/DZNr4lnY0xJK97mCbzfNBxs90OL9Pp/YWls024ZRPhezcsw=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260508.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/JxBvBLSUK0RR5c+baWcdyI1U5VuVrpXScG0IBm8oxstJ0HuFdj6LjRGqe2YbypKBz2VD2EjifLzEwXMWx6VtQ=="], @@ -477,14 +484,28 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], "enhanced-resolve": ["enhanced-resolve@5.21.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ=="], @@ -515,6 +536,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "htmx.org": ["htmx.org@2.0.10", "", {}, "sha512-kdeJe7ZVwaS6QMz/ebBIVtZdpwen6L0OQ5GOhPV9MKBb196TCZeZu4yA7ZIQsaLKv7EpXz+So7KSXNuHXhj7Cw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -523,12 +546,16 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "knip": ["knip@6.12.2", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "minimist": "^1.2.8", "oxc-parser": "^0.128.0", "oxc-resolver": "^11.19.1", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.16", "unbash": "^3.0.0", "yaml": "^2.8.2", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-RcZpT1sVziKZgDk1F0hAcp+bq71VJAF8vg1Y9ZLXc1+UXQaMm1rjiUqpJQTIj+lqwmiBQT19/u7ikgazs23cvA=="], + "launder": ["launder@1.7.1", "", { "dependencies": { "dayjs": "^1.11.7" } }, "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -579,18 +606,24 @@ "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], + "partysocket": ["partysocket@1.1.18", "", { "dependencies": { "event-target-polyfill": "^0.0.4" }, "peerDependencies": { "react": ">=17" }, "optionalPeers": ["react"] }, "sha512-SyuvH9VavWOSa14v6dYdp3yfSUDII4BQB1+TkGOFBkjfZKjnDBiba4fhdhwBlqGBkqw4ea3gTA1DYhSffX24Wg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "sanitize-html": ["sanitize-html@2.17.4", "", { "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } }, "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -650,5 +683,11 @@ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "sanitize-html/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], } } diff --git a/package.json b/package.json index 61b6319..315c077 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/d3": "^7.4.3", "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", + "@types/sanitize-html": "^2.16.1", "@typescript/native-preview": "^7.0.0-dev.20260507.1", "htmx.org": "^2.0.10", "knip": "^6.12.1", @@ -50,10 +51,12 @@ "@sindresorhus/slugify": "^3.0.0", "diff": "^9.0.0", "diff-match-patch": "^1.0.5", + "dompurify": "^3.4.5", "elysia": "^1.4.28", "fflate": "^0.8.2", "katex": "^0.16.45", "markdown-it": "^14.1.1", + "sanitize-html": "^2.17.4", "sharp": "^0.34.5" }, "overrides": { diff --git a/public/csrf.js b/public/csrf.js new file mode 100644 index 0000000..1cd5d83 --- /dev/null +++ b/public/csrf.js @@ -0,0 +1,41 @@ +// Auto-injects the CSRF token (from ) into: +// - every form submission (POST/PUT/PATCH/DELETE) as a hidden _csrf field +// - every HTMX non-GET request as an X-CSRF-Token header +// SameSite=Lax cookies already block cross-site form posts; this is the +// defense-in-depth layer on top of that. +(() => { + function token() { + var m = document.querySelector('meta[name="csrf-token"]'); + return m ? m.getAttribute("content") : null; + } + + function isUnsafe(method) { + var m = (method || "GET").toUpperCase(); + return m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE"; + } + + document.addEventListener( + "submit", + (e) => { + var form = e.target; + if (!form || form.tagName !== "FORM") return; + if (!isUnsafe(form.method)) return; + if (form.querySelector('input[name="_csrf"]')) return; + var t = token(); + if (!t) return; + var input = document.createElement("input"); + input.type = "hidden"; + input.name = "_csrf"; + input.value = t; + form.appendChild(input); + }, + true, + ); + + document.body.addEventListener("htmx:configRequest", (evt) => { + if (!isUnsafe(evt.detail.verb)) return; + var t = token(); + if (!t) return; + evt.detail.headers["X-CSRF-Token"] = t; + }); +})(); diff --git a/public/editor/preview.ts b/public/editor/preview.ts index e5dfa29..3c9d8d1 100644 --- a/public/editor/preview.ts +++ b/public/editor/preview.ts @@ -1,3 +1,4 @@ +import DOMPurify from "dompurify"; import MarkdownIt from "markdown-it"; import { katexPlugin } from "../../src/lib/markdown/katex-plugin.ts"; import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; @@ -6,11 +7,27 @@ const md = new MarkdownIt({ html: false, linkify: true }); md.use(wikilinkPlugin); md.use(katexPlugin, { throwOnError: false }); +// Matches the server-side allowlist closely. The preview is for the author's +// own draft, but a malformed markdown-it plugin could still produce something +// dangerous — DOMPurify is cheap defence-in-depth. +const PURIFY_CONFIG = { + ADD_TAGS: ["iframe"], + ADD_ATTR: [ + "allow", + "allowfullscreen", + "loading", + "frameborder", + "data-viz-type", + "data-viz", + ], +}; + function getWikiSlug(): string | undefined { const match = window.location.pathname.match(/^\/wiki\/([^/]+)/); return match?.[1]; } export function renderPreview(preview: HTMLElement, doc: string): void { - preview.innerHTML = md.render(doc, { wikiSlug: getWikiSlug() }); + const dirty = md.render(doc, { wikiSlug: getWikiSlug() }); + preview.innerHTML = DOMPurify.sanitize(dirty, PURIFY_CONFIG); } diff --git a/public/editor/upload.ts b/public/editor/upload.ts index 0d3096e..ef754c9 100644 --- a/public/editor/upload.ts +++ b/public/editor/upload.ts @@ -28,6 +28,9 @@ export async function uploadImage( const formData = new FormData(); formData.append("file", file); + const csrfMeta = document.querySelector('meta[name="csrf-token"]'); + const csrfToken = csrfMeta?.getAttribute("content") ?? ""; + if (csrfToken) formData.append("_csrf", csrfToken); try { const resp = await fetch("/api/upload-image", { diff --git a/public/home-picker.js b/public/home-picker.js new file mode 100644 index 0000000..0d3d5c4 --- /dev/null +++ b/public/home-picker.js @@ -0,0 +1,75 @@ +// Combobox picker for the wiki "home slug" setting. Triggered by the +// presence of a `[data-home-picker]` root in the settings page. +(() => { + const root = document.querySelector("[data-home-picker]"); + if (!root) return; + const input = root.querySelector("input"); + const list = root.querySelector("ul"); + if (!input || !list) return; + const items = Array.from(list.querySelectorAll("li")); + + function filter() { + const q = input.value.trim().toLowerCase(); + let any = false; + for (const li of items) { + const slug = (li.dataset.slug || "").toLowerCase(); + const title = li.dataset.title || ""; + const match = !q || slug.includes(q) || title.includes(q); + li.hidden = !match; + if (match) any = true; + } + list.hidden = !any || document.activeElement !== input; + input.setAttribute("aria-expanded", String(!list.hidden)); + } + + function pick(li) { + input.value = li.dataset.slug || ""; + list.hidden = true; + input.setAttribute("aria-expanded", "false"); + input.focus(); + } + + input.addEventListener("focus", filter); + input.addEventListener("input", filter); + input.addEventListener("blur", () => { + setTimeout(() => { + list.hidden = true; + input.setAttribute("aria-expanded", "false"); + }, 120); + }); + input.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + list.hidden = true; + return; + } + if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Enter") + return; + const visible = items.filter((li) => !li.hidden); + if (!visible.length) return; + const current = visible.findIndex((li) => li.dataset.active === "1"); + if (e.key === "Enter" && current >= 0) { + e.preventDefault(); + pick(visible[current]); + return; + } + e.preventDefault(); + const next = + e.key === "ArrowDown" + ? (current + 1) % visible.length + : (current - 1 + visible.length) % visible.length; + for (const li of visible) { + li.dataset.active = "0"; + li.style.background = ""; + } + visible[next].dataset.active = "1"; + visible[next].style.background = "var(--accent-soft)"; + visible[next].scrollIntoView({ block: "nearest" }); + }); + list.addEventListener("mousedown", (e) => { + const li = e.target.closest("li"); + if (li) { + e.preventDefault(); + pick(li); + } + }); +})(); diff --git a/public/ui.js b/public/ui.js new file mode 100644 index 0000000..22901b5 --- /dev/null +++ b/public/ui.js @@ -0,0 +1,229 @@ +// Delegated UI behaviors. Replaces every inline on* handler in the views so +// a strict CSP (no 'unsafe-inline' in script-src) can be applied. +// +// All behaviors are triggered by `data-action="..."` attributes (with helper +// data-* attributes carrying parameters) so views only emit declarative +// markup and never executable strings. +(() => { + function $(id) { + return document.getElementById(id); + } + + function postForm(url, body) { + const meta = document.querySelector('meta[name="csrf-token"]'); + const csrf = meta ? meta.getAttribute("content") || "" : ""; + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-CSRF-Token": csrf, + }, + body: body, + }); + } + + const PICKER_PAIRS = [ + ["locale-picker", "locale-menu"], + ["theme-picker", "theme-menu"], + ["profile-picker", "profile-menu"], + ["export-picker", "export-menu"], + ]; + + document.addEventListener("click", (e) => { + const target = e.target instanceof Element ? e.target : null; + if (!target) return; + + // Outside-click dismiss for popup menus. + for (const pair of PICKER_PAIRS) { + const picker = $(pair[0]); + if (picker && !picker.contains(target)) { + const m = $(pair[1]); + if (m) m.classList.add("hidden"); + } + } + + // Close search modal when any result link is clicked. + if (target.closest("#search-results a")) { + $("search-modal")?.close(); + } + + const trigger = target.closest("[data-action]"); + if (!trigger) return; + const action = trigger.getAttribute("data-action"); + + if (action === "toggle") { + const id = trigger.getAttribute("data-target"); + if (id) $(id)?.classList.toggle("hidden"); + return; + } + + if (action === "open-search") { + e.preventDefault(); + $("search-modal")?.showModal(); + $("search-input")?.focus(); + return; + } + + if (action === "close-search-on-link") { + const link = target.closest("a"); + if (link) $("search-modal")?.close(); + return; + } + + if (action === "set-locale") { + e.preventDefault(); + const v = trigger.getAttribute("data-locale") || ""; + postForm("/set-locale", `locale=${encodeURIComponent(v)}`).then(() => { + location.reload(); + }); + return; + } + + if (action === "set-theme") { + e.preventDefault(); + const v = trigger.getAttribute("data-theme") || ""; + postForm("/set-theme", `theme=${encodeURIComponent(v)}`).then(() => { + location.reload(); + }); + return; + } + + if (action === "click-target") { + e.preventDefault(); + const id = trigger.getAttribute("data-target"); + if (id) $(id)?.click(); + return; + } + + if (action === "confirm-then-submit") { + const formId = trigger.getAttribute("data-form"); + const msg = trigger.getAttribute("data-confirm") || ""; + if (confirm(msg) && formId) { + $(formId)?.submit(); + } + return; + } + + if (action === "close-dialog") { + const dialog = target.closest("dialog"); + if (dialog && target === dialog) dialog.close(); + return; + } + + if (action === "stop") { + e.stopPropagation(); + return; + } + }); + + // Dialog backdrop dismiss (the dialog itself uses data-action="close-dialog" + // via its click handler — see search modal in layout). + document.addEventListener("click", (e) => { + const dlg = e.target; + if (dlg && dlg.tagName === "DIALOG" && dlg.id === "search-modal") { + if (e.target === dlg) dlg.close(); + } + }); + + // Change-event behaviors: file-input filename mirror, visibility-toggle + // based on a value, original-vs-new compare for save buttons. + document.addEventListener("change", (e) => { + const t = e.target; + if (!(t instanceof HTMLElement)) return; + + const filenameTarget = t.getAttribute("data-filename-target"); + if (filenameTarget && t instanceof HTMLInputElement) { + const label = $(filenameTarget); + if (label) { + const f = t.files?.[0]; + label.textContent = f ? f.name : ""; + } + } + + const showWhenTarget = t.getAttribute("data-show-when-target"); + const showWhenValue = t.getAttribute("data-show-when-value"); + if (showWhenTarget && showWhenValue !== null && "value" in t) { + const el = $(showWhenTarget); + if (el) el.classList.toggle("hidden", t.value !== showWhenValue); + } + + const original = t.getAttribute("data-original"); + const revealNext = t.getAttribute("data-reveal-next"); + if ( + original !== null && + revealNext === "true" && + t instanceof HTMLSelectElement + ) { + const sib = t.nextElementSibling; + if (sib) sib.classList.toggle("hidden", t.value === original); + } + + const htmxTrigger = t.getAttribute("data-htmx-trigger"); + if (htmxTrigger && window.htmx) { + const parts = htmxTrigger.split("|"); + const targetId = parts[0]; + const evtName = parts[1] || "change"; + const targetEl = targetId ? $(targetId) : null; + if (targetEl) window.htmx.trigger(targetEl, evtName); + } + }); + + // Form submit guards: wiki delete confirmation, etc. + document.addEventListener("submit", (e) => { + const f = e.target; + if (!(f instanceof HTMLFormElement)) return; + + const expected = f.getAttribute("data-confirm-name"); + if (expected) { + const input = f.querySelector('[name="confirm"]'); + if (input && input.value !== expected) { + alert( + f.getAttribute("data-mismatch-msg") || "Confirmation did not match.", + ); + e.preventDefault(); + } + } + }); + + // Global search shortcut: opt-in via data-search-shortcut on . + document.addEventListener("keydown", (e) => { + if (!document.body.dataset.searchShortcut) return; + const target = + e.target instanceof Element ? e.target : document.activeElement; + if ( + target instanceof HTMLElement && + (target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || + target.closest( + 'input, textarea, select, [contenteditable="true"], .cm-editor', + )) + ) { + return; + } + const slash = e.key === "/" && !e.ctrlKey && !e.metaKey; + const cmdK = (e.ctrlKey || e.metaKey) && e.key === "k"; + if (!slash && !cmdK) return; + e.preventDefault(); + const inlineSearch = $("wiki-search"); + if (inlineSearch) { + inlineSearch.focus(); + inlineSearch.select(); + } else { + $("search-modal")?.showModal(); + $("search-input")?.focus(); + } + }); + + // HTMX response-error toast. + if (document.body) { + document.body.addEventListener("htmx:responseError", () => { + const toast = $("htmx-toast"); + if (!toast) return; + toast.classList.remove("hidden"); + clearTimeout(window._htmxToastTimeout); + window._htmxToastTimeout = setTimeout(() => { + toast.classList.add("hidden"); + }, 3000); + }); + } +})(); diff --git a/src/atproto/routes.ts b/src/atproto/routes.ts index 738405b..797669c 100644 --- a/src/atproto/routes.ts +++ b/src/atproto/routes.ts @@ -1,5 +1,6 @@ import type { ActorIdentifier } from "@atcute/lexicons"; import { Elysia } from "elysia"; +import { verifyCsrfForm } from "../lib/csrf.ts"; import { escapeHtml } from "../lib/html.ts"; import { fmt, resolveLocale, t } from "../lib/i18n/index.ts"; import { LIMITS } from "../lib/limits.ts"; @@ -7,7 +8,7 @@ import { getClientIp, rateLimit } from "../lib/rate-limit.ts"; import { htmlResponse } from "../lib/response.ts"; import { loginPage } from "../views/login.ts"; import { getDevAccounts } from "./env.ts"; -import { getClient } from "./session.ts"; +import { getClient, getSessionFromRequest } from "./session.ts"; function getSafeReturnTo(cookieHeader: string | null): string { const match = cookieHeader?.match(/(?:^|;\s*)returnTo=([^;]+)/); @@ -137,7 +138,12 @@ export function atprotoRoutes() { }); } }) - .post("/logout", () => { + .post("/logout", async ({ request }) => { + // CSRF-verify so a malicious page can't force the user to log out. + const session = await getSessionFromRequest(request); + if (session) { + await verifyCsrfForm(request, session.did); + } return new Response(null, { status: 302, headers: { diff --git a/src/lib/access.ts b/src/lib/access.ts index 13cf3da..42031d0 100644 --- a/src/lib/access.ts +++ b/src/lib/access.ts @@ -6,6 +6,7 @@ import { getWiki, } from "../server/db/queries/index.ts"; import { resolveUserTheme, type UserTheme } from "../views/theme/index.ts"; +import { csrfTokenFor } from "./csrf.ts"; import { ForbiddenError, NotFoundError } from "./errors.ts"; import { type Locale, resolveLocale } from "./i18n/index.ts"; import { resolveHandleToDid, resolveProfile } from "./profile.ts"; @@ -65,6 +66,8 @@ export interface RequestContext { userTheme: UserTheme; /** true only when access is "none" and the user has a pending access request */ hasPendingRequest: boolean; + /** CSRF token for authenticated sessions; null when the user is logged out. */ + csrfToken: string | null; } /** RequestContext with wiki guaranteed non-null and ownerHandle resolved. */ @@ -122,6 +125,7 @@ export async function resolveRequestContext( ): Promise { const session = await getSessionFromRequest(request); const did = session?.did ?? null; + const csrfToken = did ? csrfTokenFor(did) : null; const cookieHeader = request.headers.get("cookie"); const locale = resolveLocale( cookieHeader, @@ -129,14 +133,13 @@ export async function resolveRequestContext( ); const userTheme = resolveUserTheme(cookieHeader); + const baseShared = { session, did, locale, userTheme, csrfToken } as const; + if (!ownerHandle || !wikiSlug) { return { - session, + ...baseShared, wiki: null, - did, access: "none", - locale, - userTheme, hasPendingRequest: false, }; } @@ -144,12 +147,9 @@ export async function resolveRequestContext( const ownerDid = await resolveHandleToDid(ownerHandle); if (!ownerDid) { return { - session, + ...baseShared, wiki: null, - did, access: "none", - locale, - userTheme, hasPendingRequest: false, }; } @@ -157,12 +157,9 @@ export async function resolveRequestContext( const wiki = getWiki(ownerDid, wikiSlug); if (!wiki) { return { - session, + ...baseShared, wiki: null, - did, access: "none", - locale, - userTheme, hasPendingRequest: false, }; } @@ -175,13 +172,10 @@ export async function resolveRequestContext( const profile = await resolveProfile(wiki.did); return { - session, + ...baseShared, wiki, ownerHandle: profile.handle, - did, access, - locale, - userTheme, hasPendingRequest, }; } diff --git a/src/lib/csrf.ts b/src/lib/csrf.ts new file mode 100644 index 0000000..38b36be --- /dev/null +++ b/src/lib/csrf.ts @@ -0,0 +1,61 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { ForbiddenError } from "./errors.ts"; + +// Stateless CSRF: token = HMAC(secret, did). Bound to the session DID so a +// cross-origin attacker without read access to the session cookie can't forge +// a valid token. SameSite=Lax on the session cookie already blocks the basic +// CSRF case — this is the defense-in-depth layer the project's known-improvements +// list called out. +// +// The secret is read from CSRF_SECRET if provided, otherwise generated per +// process. A per-process secret means tokens invalidate across restarts, which +// is fine: the cost is a single resubmit on the rare destructive form open +// across a deploy. Set CSRF_SECRET to share state across processes. +const CSRF_SECRET = + process.env["CSRF_SECRET"] ?? randomBytes(32).toString("hex"); +const CSRF_FIELD = "_csrf"; + +export function csrfTokenFor(did: string): string { + return createHmac("sha256", CSRF_SECRET).update(did).digest("base64url"); +} + +/** + * Verify a CSRF token. Throws ForbiddenError on mismatch. + * Call from destructive POST handlers after the session has been resolved. + */ +export function verifyCsrfToken(submitted: string | null, did: string): void { + if (!submitted) throw new ForbiddenError("Missing CSRF token"); + const expected = csrfTokenFor(did); + const a = Buffer.from(submitted, "base64url"); + const b = Buffer.from(expected, "base64url"); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new ForbiddenError("Invalid CSRF token"); + } +} + +/** + * Read form data and verify CSRF. Accepts the token from either the hidden + * _csrf form field (standard forms) or the X-CSRF-Token header (HTMX requests + * and bodyless POSTs). Throws ForbiddenError if `did` is null so route + * handlers don't need to non-null-assert their session. + */ +export async function verifyCsrfForm(request: Request, did: string | null) { + if (!did) throw new ForbiddenError(); + let form: ReturnType extends Promise< + infer F + > + ? F + : never; + try { + form = await request.formData(); + } catch { + // Bodyless POST — fall back to header-only check. + verifyCsrfToken(request.headers.get("X-CSRF-Token"), did); + return new FormData(); + } + const submitted = + (form.get(CSRF_FIELD) as string | null) ?? + request.headers.get("X-CSRF-Token"); + verifyCsrfToken(submitted, did); + return form; +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 9af1dbe..594bfdb 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,6 +1,7 @@ import MarkdownIt from "markdown-it"; import { headingAnchorPlugin } from "./markdown/heading-anchor-plugin.ts"; import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; +import { sanitizeMarkdownHtml } from "./markdown/sanitize.ts"; import { parseWikilinkTarget, type WikilinkEnv, @@ -71,7 +72,7 @@ export function renderMarkdown( ...(wikiSlug ? { wikiSlug } : {}), ...(ownerHandle ? { ownerHandle } : {}), }; - const html = md.render(source, env); + const html = sanitizeMarkdownHtml(md.render(source, env)); return { html, hasViz: env.hasViz === true, hasMath: env.hasMath === true }; } diff --git a/src/lib/markdown/sanitize.ts b/src/lib/markdown/sanitize.ts new file mode 100644 index 0000000..3c9b3ae --- /dev/null +++ b/src/lib/markdown/sanitize.ts @@ -0,0 +1,153 @@ +import sanitizeHtml from "sanitize-html"; + +// Allowlist sized for our markdown pipeline: +// - markdown-it core elements +// - katex math output (lots of nested spans with classes/styles) +// - heading anchors (id) +// - viz containers (data-viz-type, data-viz) +// - wikilinks (class="wikilink") +// - youtube-nocookie iframes from the youtube embed plugin +const SCHEMES = ["http", "https", "mailto"]; + +const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { + allowedTags: [ + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "br", + "hr", + "a", + "img", + "strong", + "em", + "b", + "i", + "u", + "s", + "del", + "ins", + "mark", + "sub", + "sup", + "blockquote", + "code", + "pre", + "kbd", + "samp", + "var", + "ul", + "ol", + "li", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "th", + "td", + "div", + "span", + "iframe", + // katex emits these for math rendering + "math", + "annotation", + "semantics", + "mrow", + "mi", + "mo", + "mn", + "ms", + "mtext", + "mfrac", + "msqrt", + "mroot", + "msup", + "msub", + "msubsup", + "munder", + "mover", + "munderover", + "mtable", + "mtr", + "mtd", + "mspace", + "mpadded", + "mphantom", + "menclose", + "mstyle", + "merror", + ], + allowedAttributes: { + a: ["href", "name", "target", "rel", "class", "title"], + img: ["src", "alt", "title", "width", "height", "loading", "decoding"], + iframe: [ + "src", + "width", + "height", + "allow", + "allowfullscreen", + "loading", + "class", + "frameborder", + ], + "*": ["id", "class", "style", "data-viz-type", "data-viz", "aria-hidden"], + th: ["scope", "colspan", "rowspan", "align"], + td: ["colspan", "rowspan", "align"], + // MathML attrs used by katex + math: ["xmlns", "display"], + annotation: ["encoding"], + }, + allowedSchemes: SCHEMES, + allowedSchemesByTag: { img: [...SCHEMES, "data"] }, + allowedSchemesAppliedToAttributes: ["href", "src"], + allowProtocolRelative: false, + // iframe src is locked to youtube-nocookie.com (matches the markdown-it youtube plugin) + allowedIframeHostnames: ["www.youtube-nocookie.com"], + // katex inline styles are needed for math layout; we accept the trade-off + // because the markdown source can't inject arbitrary HTML (markdown-it `html: false`). + allowedStyles: { + "*": { + color: [/^[\w#().,%\-\s]+$/], + "text-align": [/^(left|right|center|justify)$/], + background: [/^[\w#().,%\-\s]+$/], + "background-color": [/^[\w#().,%\-\s]+$/], + "font-size": [/^[0-9.]+(px|em|rem|%)$/], + "line-height": [/^[0-9.]+(px|em|rem|%)?$/], + margin: [/^[\w.\-\s]+$/], + "margin-left": [/^[\w.\-\s]+$/], + "margin-right": [/^[\w.\-\s]+$/], + "margin-top": [/^[\w.\-\s]+$/], + "margin-bottom": [/^[\w.\-\s]+$/], + padding: [/^[\w.\-\s]+$/], + "padding-left": [/^[\w.\-\s]+$/], + "padding-right": [/^[\w.\-\s]+$/], + "padding-top": [/^[\w.\-\s]+$/], + "padding-bottom": [/^[\w.\-\s]+$/], + top: [/^[\w.-]+$/], + left: [/^[\w.-]+$/], + right: [/^[\w.-]+$/], + bottom: [/^[\w.-]+$/], + position: [/^(relative|absolute|static)$/], + display: [/^(inline|inline-block|block|none|flex|inline-flex)$/], + width: [/^[0-9.]+(px|em|rem|%)$/], + height: [/^[0-9.]+(px|em|rem|%)$/], + "vertical-align": [/^[\w\-.]+$/], + "border-left": [/^[\w.\-\s#()]+$/], + "border-right": [/^[\w.\-\s#()]+$/], + "border-top": [/^[\w.\-\s#()]+$/], + "border-bottom": [/^[\w.\-\s#()]+$/], + }, + }, + // Force rel on external links — added defensively, merged with existing attrs. + transformTags: { + a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer" }, true), + }, +}; + +export function sanitizeMarkdownHtml(html: string): string { + return sanitizeHtml(html, SANITIZE_OPTIONS); +} diff --git a/src/lib/response.ts b/src/lib/response.ts index 69bae1a..20b1b21 100644 --- a/src/lib/response.ts +++ b/src/lib/response.ts @@ -3,6 +3,27 @@ interface HtmlResponseOptions { edgeCacheSeconds?: number; } +// Strict CSP — no 'unsafe-inline' for scripts. All client behaviors live in +// /public/*.js. Inline ${extraStyles} + + ${extraScripts} - + - +
-
${ options?.sidebarNotes diff --git a/src/views/new-note.ts b/src/views/new-note.ts index f2b771a..79ea46a 100644 --- a/src/views/new-note.ts +++ b/src/views/new-note.ts @@ -64,10 +64,10 @@ export function newNotePage( id="import-file" accept=".md,.markdown,.txt" class="hidden" - onchange="document.getElementById('import-filename').textContent=this.files[0]?.name||''" + data-filename-target="import-filename" >
- +
diff --git a/src/views/new-wiki.ts b/src/views/new-wiki.ts index 2d078b8..f235d19 100644 --- a/src/views/new-wiki.ts +++ b/src/views/new-wiki.ts @@ -72,10 +72,10 @@ export function newWikiPage(options?: NewWikiOptions): string { type="file" accept=".zip" class="hidden" - onchange="document.getElementById('zip-filename').textContent=this.files[0]?.name||''" + data-filename-target="zip-filename" >
- +
@@ -98,7 +98,8 @@ export function newWikiPage(options?: NewWikiOptions): string { name="visibility" required class="${inputClass}" - onchange="document.getElementById('private-warning').classList.toggle('hidden', this.value !== 'private')" + data-show-when-target="private-warning" + data-show-when-value="private" > diff --git a/src/views/settings.ts b/src/views/settings.ts index 1a110af..7e7a762 100644 --- a/src/views/settings.ts +++ b/src/views/settings.ts @@ -90,7 +90,7 @@ function renderMemberRow( const roleCell = isOwner ? `${roleLabel("owner")}` : `
- ${renderRoleOptions(m.role, roleLabel)} @@ -263,8 +263,7 @@ function renderDangerZone(

${msg.settings.deleteWikiDescription}

- + @@ -337,66 +336,6 @@ function renderHomepageSection(
- `; } @@ -540,6 +479,12 @@ export function settingsPage( return layout( `${msg.settings.heading} — ${wikiName}`, `${backLink}

${msg.settings.heading}

${errorHtml}${detailsHtml}${homepageHtml}${themeHtml}${membersHtml}${dangerHtml}`, - { ...options, wikiName, wikiSlug, noindex: true }, + { + ...options, + wikiName, + wikiSlug, + noindex: true, + scripts: [...(options.scripts ?? []), "/public/home-picker.js"], + }, ); } diff --git a/src/views/wiki-list.ts b/src/views/wiki-list.ts index 7fa7e65..b38391e 100644 --- a/src/views/wiki-list.ts +++ b/src/views/wiki-list.ts @@ -23,7 +23,7 @@ export function wikiToolbar( const sortDropdown = ``; @@ -65,7 +65,7 @@ function languageDropdown(languages: string[], locale: Locale): string { return ``; diff --git a/tests/integration/http-helpers.ts b/tests/integration/http-helpers.ts index f6901bd..fada20f 100644 --- a/tests/integration/http-helpers.ts +++ b/tests/integration/http-helpers.ts @@ -1,3 +1,4 @@ +import { csrfTokenFor } from "../../src/lib/csrf.ts"; import { buildApp } from "../../src/server/app.ts"; const app = buildApp(); @@ -8,6 +9,11 @@ interface FetchOptions { contentType?: string; } +function didFromCookie(cookie: string): string | null { + const match = cookie.match(/(?:^|;\s*)did=([^;]+)/); + return match?.[1] ? decodeURIComponent(match[1]) : null; +} + /** Send a Request through the app without binding a port. */ export function fetch( method: string, @@ -17,6 +23,16 @@ export function fetch( const headers: Record = {}; if (opts.cookie) headers["cookie"] = opts.cookie; if (opts.contentType) headers["content-type"] = opts.contentType; + const m = method.toUpperCase(); + const unsafe = m === "POST" || m === "PUT" || m === "PATCH" || m === "DELETE"; + const did = opts.cookie ? didFromCookie(opts.cookie) : null; + if (unsafe && did) { + const token = csrfTokenFor(did); + headers["x-csrf-token"] = token; + if (opts.body instanceof FormData && !opts.body.has("_csrf")) { + opts.body.append("_csrf", token); + } + } const init: RequestInit = { method, headers }; if (opts.body !== undefined) init.body = opts.body; return app.handle(new Request(`http://localhost${path}`, init)); diff --git a/tests/lib/csrf.test.ts b/tests/lib/csrf.test.ts new file mode 100644 index 0000000..6b53f8f --- /dev/null +++ b/tests/lib/csrf.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { csrfTokenFor, verifyCsrfToken } from "../../src/lib/csrf.ts"; +import { ForbiddenError } from "../../src/lib/errors.ts"; + +describe("csrf", () => { + test("token is deterministic per did", () => { + expect(csrfTokenFor("did:plc:alice")).toBe(csrfTokenFor("did:plc:alice")); + }); + + test("different dids get different tokens", () => { + expect(csrfTokenFor("did:plc:alice")).not.toBe(csrfTokenFor("did:plc:bob")); + }); + + test("verifyCsrfToken accepts a matching token", () => { + const t = csrfTokenFor("did:plc:alice"); + expect(() => { + verifyCsrfToken(t, "did:plc:alice"); + }).not.toThrow(); + }); + + test("verifyCsrfToken rejects another user's token", () => { + const bob = csrfTokenFor("did:plc:bob"); + expect(() => { + verifyCsrfToken(bob, "did:plc:alice"); + }).toThrow(ForbiddenError); + }); + + test("verifyCsrfToken rejects null", () => { + expect(() => { + verifyCsrfToken(null, "did:plc:alice"); + }).toThrow(ForbiddenError); + }); + + test("verifyCsrfToken rejects garbage", () => { + expect(() => { + verifyCsrfToken("not-a-real-token", "did:plc:alice"); + }).toThrow(ForbiddenError); + }); +}); diff --git a/tests/lib/import-export/export.test.ts b/tests/lib/import-export/export.test.ts index 3e18db2..dcc6a13 100644 --- a/tests/lib/import-export/export.test.ts +++ b/tests/lib/import-export/export.test.ts @@ -67,6 +67,7 @@ function makeCtx() { locale: "en" as const, userTheme: "system" as const, hasPendingRequest: false, + csrfToken: null, }; } diff --git a/tests/lib/import-export/import.test.ts b/tests/lib/import-export/import.test.ts index fe17845..60d385a 100644 --- a/tests/lib/import-export/import.test.ts +++ b/tests/lib/import-export/import.test.ts @@ -72,6 +72,7 @@ function makeCtx(overrides: Partial = {}): RequestContext { locale: "en" as const, userTheme: "system" as const, hasPendingRequest: false, + csrfToken: null, ...overrides, }; } diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index a4a67a2..f5a013b 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -174,9 +174,9 @@ describe("wikilinks", () => { test("XSS in wikilink slug: quote breakout in href is escaped", () => { const { html } = renderMarkdown('[[" onclick="alert(1)]]', WIKI); - // Quotes are escaped so the attacker can't break out of the href attribute - expect(html).not.toContain(' onclick="alert'); - expect(html).toContain("""); + // The injected sequence only survives as inert text content; no element + // gets a real onclick attribute, and the href is a slugified safe value. + expect(html).not.toMatch(/<[a-z]+\s[^>]*onclick\s*=/i); expect(html).toContain("wikilink"); }); diff --git a/tests/lib/orchestrators/membership.test.ts b/tests/lib/orchestrators/membership.test.ts index 02e489b..4094f47 100644 --- a/tests/lib/orchestrators/membership.test.ts +++ b/tests/lib/orchestrators/membership.test.ts @@ -74,6 +74,7 @@ function makeCtx( locale: "en", userTheme: "system", hasPendingRequest: false, + csrfToken: null, ...overrides, }; } diff --git a/tests/lib/orchestrators/note.test.ts b/tests/lib/orchestrators/note.test.ts index 1ae2a63..0182171 100644 --- a/tests/lib/orchestrators/note.test.ts +++ b/tests/lib/orchestrators/note.test.ts @@ -67,6 +67,7 @@ function makeCtx( locale: "en", userTheme: "system", hasPendingRequest: false, + csrfToken: null, ...overrides, }; } diff --git a/tests/lib/orchestrators/wiki.test.ts b/tests/lib/orchestrators/wiki.test.ts index c77c5c7..860af38 100644 --- a/tests/lib/orchestrators/wiki.test.ts +++ b/tests/lib/orchestrators/wiki.test.ts @@ -77,6 +77,7 @@ function makeCtx(overrides: Partial = {}): RequestContext { locale: "en", userTheme: "system", hasPendingRequest: false, + csrfToken: null, ...overrides, }; } @@ -94,6 +95,7 @@ function makeWikiCtx( locale: "en", userTheme: "system", hasPendingRequest: false, + csrfToken: null, ...overrides, }; } diff --git a/tests/lib/viz/plugin.test.ts b/tests/lib/viz/plugin.test.ts index b3bf614..139019c 100644 --- a/tests/lib/viz/plugin.test.ts +++ b/tests/lib/viz/plugin.test.ts @@ -135,8 +135,9 @@ describe("viz plugin XSS prevention", () => { test("escapes quotes in viz type name preventing attribute injection", () => { const { html } = renderMarkdown('```viz-" onmouseover="alert(1)\n{}\n```'); - // Quotes must be escaped so they can't break out of HTML attributes - expect(html).toContain("""); - expect(html).not.toContain('onmouseover="alert'); + // The malicious type name appears only inside a text node, never + // as a real HTML attribute on any element. + expect(html).toMatch(/[^<]*onmouseover[^<]*<\/code>/); + expect(html).not.toMatch(/<[a-z]+\s[^>]*onmouseover\s*=/i); }); }); diff --git a/tests/server/routes/bookmark.test.ts b/tests/server/routes/bookmark.test.ts index 02f6f43..dbe3a3b 100644 --- a/tests/server/routes/bookmark.test.ts +++ b/tests/server/routes/bookmark.test.ts @@ -51,6 +51,22 @@ describe("bookmark routes", () => { expect(isBookmarked(TEST_DID, AT_URI)).toBe(true); }); + test("rejects POST without CSRF token", async () => { + const formData = new FormData(); + formData.set("wikiAtUri", AT_URI); + formData.set("action", "add"); + // Bypass authedRequest helper — go straight to a raw Request so no _csrf + // is auto-injected. + const res = await app.handle( + new Request("http://localhost/api/bookmark", { + method: "POST", + headers: { cookie: `did=${encodeURIComponent(TEST_DID)}` }, + body: formData, + }), + ); + expect(res.status).toBe(403); + }); + test("removes bookmark and returns HTML partial", async () => { const formData = new FormData(); formData.set("wikiAtUri", AT_URI); diff --git a/tests/server/routes/helpers.ts b/tests/server/routes/helpers.ts index b7ada03..050522b 100644 --- a/tests/server/routes/helpers.ts +++ b/tests/server/routes/helpers.ts @@ -1,5 +1,6 @@ import { mock } from "bun:test"; import { Elysia } from "elysia"; +import { csrfTokenFor } from "../../../src/lib/csrf.ts"; import { AppError } from "../../../src/lib/errors.ts"; export const TEST_DID = "did:plc:test-user"; @@ -72,7 +73,9 @@ export function createTestApp() { .use(ogRoutes); } -/** Create a Request with an authenticated session cookie. */ +/** Create a Request with an authenticated session cookie. Auto-injects the + * CSRF token for POST/PUT/PATCH/DELETE requests (FormData body adds _csrf, + * other bodies set X-CSRF-Token). */ export function authedRequest( url: string, init?: RequestInit, @@ -80,5 +83,20 @@ export function authedRequest( ): Request { const headers = new Headers(init?.headers); headers.set("cookie", `did=${encodeURIComponent(did)}`); + const method = (init?.method ?? "GET").toUpperCase(); + const unsafe = + method === "POST" || + method === "PUT" || + method === "PATCH" || + method === "DELETE"; + if (unsafe) { + const token = csrfTokenFor(did); + if (init?.body instanceof FormData && !init.body.has("_csrf")) { + init.body.append("_csrf", token); + } + if (!headers.has("X-CSRF-Token")) { + headers.set("X-CSRF-Token", token); + } + } return new Request(url, { ...init, headers }); } -- 2.51.2