Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions live/src/core/helpers/convert-document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// plane editor
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
// plane types
import { TDocumentPayload } from "@plane/types";

type TArgs = {
document_html: string;
variant: "rich" | "document";
};

export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;

let allFormats: TDocumentPayload;

if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
allFormats = {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
} else {
throw new Error(`Invalid variant provided: ${variant}`);
}

return allFormats;
};
5 changes: 5 additions & 0 deletions live/src/core/types/common.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes;
export type HocusPocusServerContext = {
cookie: string;
};

export type TConvertDocumentRequestBody = {
description_html: string;
variant: "rich" | "document";
};
50 changes: 36 additions & 14 deletions live/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import "@/core/config/sentry-config.js";

import express from "express";
import expressWs from "express-ws";
import * as Sentry from "@sentry/node";
import compression from "compression";
import helmet from "helmet";

// cors
import cors from "cors";

// core hocuspocus server
import expressWs from "express-ws";
import express from "express";
import helmet from "helmet";
// config
import "@/core/config/sentry-config.js";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";

// helpers
import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";

const app = express();
expressWs(app);
Expand All @@ -29,7 +28,7 @@ app.use(
compression({
level: 6,
threshold: 5 * 1000,
}),
})
);

// Logging middleware
Expand Down Expand Up @@ -62,6 +61,31 @@ router.ws("/collaboration", (ws, req) => {
}
});

router.post("/convert-document", (req, res) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
res.status(400).send({
message: "Missing required fields",
});
return;
}
const { description, description_binary } = convertHTMLDocumentToAllFormats({
document_html: description_html,
variant,
});
res.status(200).json({
description,
description_binary,
});
} catch (error) {
manualLogger.error("Error in /convert-document endpoint:", error);
res.status(500).send({
message: `Internal server error. ${error}`,
});
}
});
Comment on lines +64 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Based on the analysis of the codebase, I can now provide the final response:

HTML input requires additional security measures

The current implementation lacks proper HTML sanitization for the description_html input, which could lead to XSS vulnerabilities. While the codebase includes DOMPurify for sanitization in other areas (via @plane/utils), it's not being used in this endpoint. Additionally, the variant parameter needs type validation.

Required changes:

  • Import and use the sanitizeHTML utility from @plane/utils to sanitize the description_html input before processing
  • Add type validation for the variant parameter using TypeScript's union type "rich" | "document" (as seen in the convertHTMLDocumentToAllFormats implementation)

Example implementation:

import { sanitizeHTML } from "@plane/utils";

router.post("/convert-document", (req, res) => {
  const { description_html, variant } = req.body as TConvertDocumentRequestBody;
  try {
    if (description_html === undefined || variant === undefined) {
      res.status(400).send({
        message: "Missing required fields",
      });
      return;
    }
    
    // Validate variant
    if (variant !== "rich" && variant !== "document") {
      res.status(400).send({
        message: "Invalid variant value. Must be 'rich' or 'document'",
      });
      return;
    }

    // Sanitize HTML input
    const sanitizedHTML = sanitizeHTML(description_html);
    
    const { description, description_binary } = convertHTMLDocumentToAllFormats({
      document_html: sanitizedHTML,
      variant,
    });
    
    res.status(200).json({
      description,
      description_binary,
    });
  } catch (error) {
    manualLogger.error("Error in /convert-document endpoint:", error);
    res.status(500).send({
      message: `Internal server error. ${error}`,
    });
  }
});
🔗 Analysis chain

Ensure robust sanitization and validation for HTML input.
While you verify that description_html and variant are defined, consider:

  1. Sanitizing description_html to prevent XSS vulnerabilities and potential malicious injection.
  2. Validating the string to ensure only expected HTML is passed.
  3. Restricting valid variant values to “rich” or “document” to avoid unexpected behavior.

Here’s a sample snippet (no direct diff as it spans multiple lines) illustrating a possible sanitization approach using DOMPurify:

import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";

/** ... inside your conversion flow ... */
const DOMPurify = createDOMPurify(new JSDOM("").window);
const sanitizedHTML = DOMPurify.sanitize(description_html);

Would you like a more in-depth solution that includes advanced sanitization and variant validation?


Let me verify the concerns by checking the codebase for existing sanitization, validation mechanisms, and the allowed variant values.


Let me check the implementation details of the conversion function and the type definitions to better understand the validation needs.


Let me check the type definition for TConvertDocumentRequestBody and examine the editor package's HTML processing functions since they handle the actual conversion.


Let me check the editor extensions and components that might be handling HTML processing and security.


Let me check the core editor extensions to understand how HTML content is processed and secured.

🏁 Scripts executed

The following scripts were executed for the analysis:

Error: No shell scripts found


Script:

#!/bin/bash
# Check for any existing sanitization or validation logic
rg -l "sanitize|validate|purify|clean|xss" --type ts

# Look for the type definition of variant
ast-grep --pattern 'type TConvertDocumentRequestBody'

# Check for DOMPurify or similar security libraries in package.json
cat package.json

# Look for the implementation of convertHTMLDocumentToAllFormats to understand variant handling
ast-grep --pattern 'convertHTMLDocumentToAllFormats'

Length of output: 4675


Script:

#!/bin/bash
# Check the implementation of convertHTMLDocumentToAllFormats and related types
cat live/src/core/helpers/convert-document.ts

# Look for any existing input validation in the server file
rg -A 5 -B 5 "TConvertDocumentRequestBody" live/src/server.ts

# Check if there are any security-related utilities or sanitization helpers
rg -l "sanitizeHTML|cleanHTML|purifyHTML" --type ts

# Look for any HTML processing or security-related dependencies in live/package.json
cat live/package.json

Length of output: 4335


Script:

#!/bin/bash
# Check the type definition
cat live/src/core/types/common.ts

# Look into the editor package's HTML processing
rg -A 10 "getBinaryDataFromRichTextEditorHTMLString|getBinaryDataFromDocumentEditorHTMLString" packages/editor/src/

# Check if there's any sanitization in the editor package
rg -l "sanitize|xss|clean|purify" packages/editor/src/

Length of output: 2374


Script:

#!/bin/bash
# Check the editor extensions and components for HTML handling
cat packages/editor/src/core/extensions/callout/utils.ts
cat packages/editor/src/core/components/editors/document/page-renderer.tsx

# Look for the RICH_TEXT_EDITOR_EXTENSIONS and DOCUMENT_EDITOR_EXTENSIONS configuration
rg -A 5 "RICH_TEXT_EDITOR_EXTENSIONS|DOCUMENT_EDITOR_EXTENSIONS" packages/editor/src/

# Check the TiptapEditor configuration since it's being used
rg -A 10 "generateJSON|generateHTML" packages/editor/src/

Length of output: 15585


Script:

#!/bin/bash
# Check the core editor extensions
cat packages/editor/src/extensions/core-without-props.ts

# Look for any HTML sanitization utilities in @plane/utils
cat packages/utils/src/string.ts

# Check if there are any security-related configurations in the editor
rg -A 5 "allowedTags|sanitize|security" packages/editor/src/

Length of output: 12207


app.use(process.env.LIVE_BASE_PATH || "/live", router);

app.use((_req, res) => {
Expand All @@ -82,9 +106,7 @@ const gracefulShutdown = async () => {
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
manualLogger.info("HocusPocus server WebSocket connections closed gracefully.");

// Close the Express server
liveServer.close(() => {
Expand Down
136 changes: 136 additions & 0 deletions packages/editor/src/core/helpers/yjs-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs";
// extensions
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@/extensions/core-without-props";

// editor extension configs
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
// editor schemas
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);

/**
* @description apply updates to a doc and return the updated doc in binary format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {Uint8Array}
*/
export const applyUpdates = (document: Uint8Array, updates?: Uint8Array): Uint8Array => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
if (updates) {
Y.applyUpdate(yDoc, updates);
}

const encodedDoc = Y.encodeStateAsUpdate(yDoc);
return encodedDoc;
};

/**
* @description this function encodes binary data to base64 string
* @param {Uint8Array} document
* @returns {string}
*/
export const convertBinaryDataToBase64String = (document: Uint8Array): string =>
Buffer.from(document).toString("base64");

/**
* @description this function decodes base64 string to binary data
* @param {string} document
* @returns {ArrayBuffer}
*/
export const convertBase64StringToBinaryData = (document: string): ArrayBuffer => Buffer.from(document, "base64");

/**
* @description this function generates the binary equivalent of html content for the rich text editor
* @param {string} descriptionHTML
* @returns {Uint8Array}
*/
export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", RICH_TEXT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(richTextEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};

/**
* @description this function generates the binary equivalent of html content for the document editor
* @param {string} descriptionHTML
* @returns {Uint8Array}
*/
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return encodedData;
};

/**
* @description this function generates all document formats for the provided binary data for the rich text editor
* @param {Uint8Array} description
* @returns
*/
export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, richTextEditorSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, RICH_TEXT_EDITOR_EXTENSIONS);

return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};

/**
* @description this function generates all document formats for the provided binary data for the document editor
* @param {Uint8Array} description
* @returns
*/
export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
description: Uint8Array
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = convertBinaryDataToBase64String(description);
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);

return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
16 changes: 0 additions & 16 deletions packages/editor/src/core/helpers/yjs.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export * from "@/constants/common";
// helpers
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs";
export * from "@/helpers/yjs-utils";
export * from "@/extensions/table/table";

// components
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "@/extensions/core-without-props";
export * from "@/constants/document-collaborative-events";
export * from "@/helpers/get-document-server-event";
export * from "@/helpers/yjs-utils";
export * from "@/types/document-collaborative-events";
Loading