From 76b2c584611eda4d2b5758775e0322dfab52ff07 Mon Sep 17 00:00:00 2001
From: Henry <30578846+hjball@users.noreply.github.com>
Date: Mon, 16 Mar 2026 14:31:13 +0000
Subject: [PATCH] fix: security improvements to checklist input (#444)
* feat: sanitize input for checklists
* feat: add basic editor
* feat: use plain text editor for checklist items
* feat: remove key from user response
---
apps/web/src/components/PlainTextEditor.tsx | 130 ++++++++++++++++++
.../card/components/ChecklistItemRow.tsx | 69 +++-------
packages/api/src/routers/checklist.ts | 47 ++++---
packages/api/src/routers/user.ts | 3 +-
packages/shared/src/utils/index.ts | 1 +
packages/shared/src/utils/sanitize.ts | 6 +
6 files changed, 179 insertions(+), 77 deletions(-)
create mode 100644 apps/web/src/components/PlainTextEditor.tsx
create mode 100644 packages/shared/src/utils/sanitize.ts
diff --git a/apps/web/src/components/PlainTextEditor.tsx b/apps/web/src/components/PlainTextEditor.tsx
new file mode 100644
index 00000000..8525ad1c
--- /dev/null
+++ b/apps/web/src/components/PlainTextEditor.tsx
@@ -0,0 +1,130 @@
+import Placeholder from "@tiptap/extension-placeholder";
+import { EditorContent, useEditor } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import { useEffect, useRef } from "react";
+import { twMerge } from "tailwind-merge";
+
+interface PlainTextEditorProps {
+ content: string;
+ onChange?: (value: string) => void;
+ onBlur?: (value: string) => void;
+ onEnter?: (value: string) => void;
+ onEscape?: () => void;
+ readOnly?: boolean;
+ placeholder?: string;
+ className?: string;
+}
+
+export default function PlainTextEditor({
+ content,
+ onChange,
+ onBlur,
+ onEnter,
+ onEscape,
+ readOnly = false,
+ placeholder,
+ className,
+}: PlainTextEditorProps) {
+ const onEnterRef = useRef(onEnter);
+ const onEscapeRef = useRef(onEscape);
+ const onBlurRef = useRef(onBlur);
+ const onChangeRef = useRef(onChange);
+ const contentRef = useRef(content);
+
+ useEffect(() => {
+ onEnterRef.current = onEnter;
+ }, [onEnter]);
+ useEffect(() => {
+ onEscapeRef.current = onEscape;
+ }, [onEscape]);
+ useEffect(() => {
+ onBlurRef.current = onBlur;
+ }, [onBlur]);
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ }, [onChange]);
+ useEffect(() => {
+ contentRef.current = content;
+ }, [content]);
+
+ const editor = useEditor(
+ {
+ extensions: [
+ StarterKit.configure({
+ bold: false,
+ italic: false,
+ strike: false,
+ code: false,
+ codeBlock: false,
+ blockquote: false,
+ heading: false,
+ bulletList: false,
+ orderedList: false,
+ listItem: false,
+ horizontalRule: false,
+ hardBreak: false,
+ }),
+ Placeholder.configure({ placeholder }),
+ ],
+ content,
+ editable: !readOnly,
+ onUpdate: ({ editor }) => onChangeRef.current?.(editor.getText()),
+ onBlur: ({ editor }) => onBlurRef.current?.(editor.getText()),
+ editorProps: {
+ handleKeyDown: (view, event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ onEnterRef.current?.(view.state.doc.textContent.trim());
+ return true;
+ }
+ if (event.key === "Escape") {
+ event.preventDefault();
+ // Reset to original content before calling the callback
+ editor?.commands.setContent(contentRef.current, false);
+ editor?.commands.blur();
+ onEscapeRef.current?.();
+ return true;
+ }
+ return false;
+ },
+ attributes: {
+ class: "outline-none focus:outline-none focus-visible:ring-0",
+ },
+ },
+ },
+ [],
+ );
+
+ useEffect(() => {
+ if (!editor) return;
+ if (content !== editor.getText()) {
+ editor.commands.setContent(content, false);
+ }
+ }, [content, editor]);
+
+ useEffect(() => {
+ if (!editor) return;
+ editor.setEditable(!readOnly);
+ }, [readOnly, editor]);
+
+ return (
+ <>
+
+
(\n)?/gi, "\n")
- .replace(/